'use client' import { useCallback, useEffect, useRef, useState } from 'react' import { AlertCircle, CheckCircle2, Circle, Download, ExternalLink, Link2, Link2Off, Loader2, Lock, PlugZap, Send, ShieldAlert, Unlock, } from 'lucide-react' import { useTranslations } from 'next-intl' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { InfoTooltip } from '@/components/ui/info-tooltip' import { useToast } from '@/components/ui/use-toast' import { UpgradeNote } from '@/components/billing/UpgradeNote' import { useCapability } from '@/contexts/CompanyContext' import { isAllowedSkvPopupOrigin } from '@/lib/skatteverket/popup-origin' import { CAPABILITY } from '@/lib/entitlements/keys' import { resolveRunAgiSubmission, type AgiSubmissionState, } from '@/lib/salary/agi-submission-state' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' 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 /** * Per-period submission record, owned by the parent (via useAgiSubmission) * so the progress rail and hero can render the same state machine. * * Deliberately period-scoped, matching Skatteverket: the underlag, the * granskningsunderlag lock and lasUpp all address a redovisningsperiod, not * a run. What is run-scoped is the filing receipt, and the panel narrows the * record itself (see `runSubmission` below) rather than asking the parent to. */ submission: AgiSubmissionState | null /** Refetch the submission record after a state-changing action. */ onRefreshSubmission: () => void /** 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 } /** 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' /** One-click chain steps, in execution order. */ const CHAIN_STEPS = ['generate', 'submit', 'kontroll', 'link'] as const type ChainStep = (typeof CHAIN_STEPS)[number] interface ChainProgress { current: ChainStep failed: boolean done: boolean } /** * Sentinel for chain aborts where the failing step already surfaced its * error via setError/setKontroller: the catch block must not overwrite it. */ class ChainFailed extends Error {} /** * Extract a human message from either the canonical { error: { message } } * envelope (internal routes) or a plain { error: string } (extension routes). */ function errText(data: unknown): string | null { if (!data || typeof data !== 'object') return null const err = (data as { error?: unknown }).error if (typeof err === 'string') return err if (err && typeof err === 'object' && typeof (err as { message?: unknown }).message === 'string') { return (err as { message: string }).message } return null } export function AGIPanel(props: AGIPanelProps) { const { salaryRunId, arbetsgivare, period, agiGeneratedAt, agiSubmittedAt, submission, onRefreshSubmission, readOnly, onChange, } = props const t = useTranslations('salary_agi') const { toast } = useToast() const hasSkatteverket = useCapability(CAPABILITY.skatteverket) const [extensionDisabled, setExtensionDisabled] = useState(false) const [status, setStatus] = 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 [chain, setChain] = useState(null) const [showAdvanced, setShowAdvanced] = useState(false) // True while an OAuth tab opened from this panel is still alive. Disables // the connect buttons so a second click cannot start a parallel flow: each // /authorize call overwrites the stored oauth_state + PKCE verifier, so a // parallel flow guarantees a CSRF failure for whichever tab finishes last. const [connecting, setConnecting] = useState(false) // "2026-06" for user-facing copy; the period prop is compact YYYYMM. const prettyPeriod = `${period.slice(0, 4)}-${period.slice(4)}` // ── Derived filing state (needed by hooks, so derived before any return) ── // Two scopes live side by side below, deliberately. // // PERIOD scope (`submission`): the in-flight machine. Skatteverket locks a // redovisningsperiod, not a run, so when a corrected month holds two runs the // second one must still see, and be able to unlock, the draft that blocks it. const subState = submission?.status const awaitingSigning = subState === 'awaiting_signing' const underlagSubmitted = subState === 'underlag_submitted' const underlagRejected = subState === 'underlag_rejected' // RUN scope (`runSubmission`): the filing receipt. A correction is a complete // replacement declaration for the same period (same specifikationsnummer per // employee) filed on its own, so it gets its OWN kvittens. Reading the period // record raw here would render the correction as already filed and print the // superseded declaration's kvittensnummer on it. const runSubmission = resolveRunAgiSubmission( { id: salaryRunId, agi_generated_at: agiGeneratedAt, agi_submitted_at: agiSubmittedAt }, submission, ) const isSigned = runSubmission?.status === '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() 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) } }, []) useEffect(() => { fetchStatus() }, [fetchStatus]) // Handle of the OAuth tab opened by handleConnect: used to verify the // sender identity of incoming postMessages and to detect abandonment. const popupRef = useRef(null) const watchTimerRef = useRef | null>(null) const delayedRefetchRef = useRef | null>(null) const stopWatchingOauthTab = useCallback(() => { if (watchTimerRef.current) { clearInterval(watchTimerRef.current) watchTimerRef.current = null } setConnecting(false) }, []) useEffect(() => { return () => { if (watchTimerRef.current) clearInterval(watchTimerRef.current) if (delayedRefetchRef.current) clearTimeout(delayedRefetchRef.current) } }, []) // 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) { // The popup runs on the pinned SKV OAuth host, which differs from the // app origin after the app.accounted.se cutover. if (!isAllowedSkvPopupOrigin(event.origin, window.location.origin)) return // Source-identity check: only the popup this component opened can // trigger the handler; a window reference cannot be forged by other // same-origin scripts. if (!popupRef.current || event.source !== popupRef.current) return if (event.data?.type === 'skatteverket-oauth-success') { stopWatchingOauthTab() setError(null) setSuccess(t('oauth_success')) fetchStatus() // Verified success: rebroadcast as an internal DOM event so passive // consumers (e.g. the salary page) can react without trusting raw // postMessage. window.dispatchEvent(new CustomEvent('skatteverket-connection-updated')) // The post-connect refresh (skattekonto sync, AGI settle, token // health) now runs server-side AFTER the callback responds, so the // status fetched above predates it. Refetch once more when it has // plausibly settled so synced data and health flags show up // without a manual reload. if (delayedRefetchRef.current) clearTimeout(delayedRefetchRef.current) delayedRefetchRef.current = setTimeout(() => { fetchStatus() window.dispatchEvent(new CustomEvent('skatteverket-connection-updated')) }, 15_000) } else if (event.data?.type === 'skatteverket-oauth-error') { stopWatchingOauthTab() const reason = typeof event.data.reason === 'string' && event.data.reason ? event.data.reason : t('oauth_error_fallback') setError(reason) } } window.addEventListener('message', handleMessage) return () => window.removeEventListener('message', handleMessage) }, [fetchStatus, stopWatchingOauthTab, t]) // 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]) // Loud success when the filing completes: a poll (live timers, tab refocus, // or the parent's refresh) flips isSigned while the user is on the page. // The ref starts null so an already-signed run doesn't toast on mount. const prevSignedRef = useRef(null) useEffect(() => { if (prevSignedRef.current === false && isSigned) { toast({ title: t('toast_signed_title'), description: t('toast_signed_description', { period: prettyPeriod }), }) } prevSignedRef.current = isSigned }, [isSigned, toast, t, prettyPeriod]) // 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 onRefreshSubmission() if (signed) { // Replace any lingering "Granskningsunderlag klart…" / stale error // with an unambiguous confirmation. Mirrors handleCheckSubmitted. setError(null) setSuccess(t('signed_success')) onChange?.() } return signed } catch { return false } }, [arbetsgivare, period, onRefreshSubmission, onChange, t]) /** * 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 () => { // No disconnect while an OAuth tab is in flight: the callback completing // right after the disconnect would silently recreate the tokens. if (connecting) return 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 || t('disconnect_failed_status', { status: res.status })) return } setSuccess(t('disconnect_success')) await fetchStatus() onRefreshSubmission() } catch (e) { setError(e instanceof Error ? getUserErrorMessage(e) : t('disconnect_failed')) } finally { setActionLoading(null) } }, [connecting, fetchStatus, onRefreshSubmission, t]) const handleConnect = () => { // Open the BankID OAuth flow in a NEW TAB, not a popup. The old 600x750 // popup could not fit Skatteverket's consent page: the approve button // sat below the fold and users got stranded mid-consent. A tab gets the // full viewport (and behaves natively on mobile). 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 tab-blocked // fallback path 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 tab = window.open(url, '_blank') popupRef.current = tab if (!tab) { // Tab blocked: fall back to a full-page navigation. window.location.href = url return } setConnecting(true) // Detect abandonment: if the tab goes away without posting a message // (closed manually, stranded on Skatteverket's side), re-enable the // buttons and refresh status. This also fires after a successful // self-close; the extra status fetch is harmless. if (watchTimerRef.current) clearInterval(watchTimerRef.current) watchTimerRef.current = setInterval(() => { if (popupRef.current?.closed) { stopWatchingOauthTab() fetchStatus() } }, 1000) } /** * 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 } /** * The XML must exist in agi_declarations before anything can be submitted. * The internal xml route both generates and persists it (and stamps * agi_generated_at); the response body, the downloadable file itself, is * discarded here: "Ladda ner AGI-fil" remains the way to get a copy. */ async function ensureAgiGenerated(): Promise { if (agiGeneratedAt) return true const res = await fetch(`/api/salary/runs/${salaryRunId}/agi/xml`) if (!res.ok) { const data = await res.json().catch(() => ({})) setError(errText(data) || t('xml_generate_failed')) return false } onChange?.() // parent refetches the run so agiGeneratedAt flips return true } /** * 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. * * Failures surface via setError/setKontroller and return false. Shared by * the one-click chain and the advanced "Skicka in underlag" button; * `onKontrollPhase` lets the chain advance its stepper when polling starts. */ async function runSubmitUnderlag(onKontrollPhase?: () => void): Promise { setKontroller([]) 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 || t('submit_failed_status', { status: submitRes.status })) return false } const inlamningId = submitJson.data?.inlamningId as number | undefined if (!inlamningId) { setError(t('submit_missing_id')) return false } // Poll kontrollresultat until DONE_* onKontrollPhase?.() 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 || t('kontrollresultat_failed_status', { status: krRes.status })) return false } kr = krJson.data as Kontrollresultat if (kr.status !== 'PROCESSING') break await new Promise(r => setTimeout(r, 1000)) } if (!kr || kr.status === 'PROCESSING') { setError(t('still_processing')) return false } const findings = extractFindings(kr) setKontroller(findings) if (kr.status === 'DONE_SUCCESS') return true if (kr.status === 'DONE_REJECTED') { setError(t('underlag_rejected_error', { count: findings.filter(f => f.status === 'STOPP').length })) } else { setError(t('underlag_failed')) } return false } /** * 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. Returns the link on success ('' when * the response carried none: the signing-link card renders it after the * submission refresh) and null on failure (error already surfaced). */ async function runCreateSigningLink(): Promise { 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 || t('signing_link_failed_status', { status: res.status })) return null } if (json.data?.tillstand === 'INCORRECT_DATA') { setError(t('incorrect_data_error', { message: json.data.meddelande || t('incorrect_data_fallback') })) return null } // 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 click "Hämta kvittens". scheduleKvittensPolls() return typeof json.data?.link === 'string' ? json.data.link : '' } /** * One-click filing: generate (if missing) → POST underlag → poll kontroll → * create signing link → hand over to BankID signing in Mina Sidor. * * The signing link opens in a tab we open synchronously at click time: * window.open after the async chain would be popup-blocked. On failure the * placeholder tab is closed and the error renders in the panel; if the * popup was blocked outright, the signing-link card (rendered from the * refreshed submission state) is the fallback path. */ const handleSubmitChain = async () => { setActionLoading('chain') setError(null) setSuccess(null) let signingTab: Window | null = null try { signingTab = window.open('', '_blank') if (signingTab) { signingTab.document.title = t('chain_tab_title') signingTab.document.body.textContent = t('chain_tab_body') } } catch { signingTab = null } try { setChain({ current: 'generate', failed: false, done: false }) if (!(await ensureAgiGenerated())) throw new ChainFailed() setChain({ current: 'submit', failed: false, done: false }) const submitted = await runSubmitUnderlag(() => setChain({ current: 'kontroll', failed: false, done: false }), ) if (!submitted) throw new ChainFailed() setChain({ current: 'link', failed: false, done: false }) const link = await runCreateSigningLink() if (link === null) throw new ChainFailed() setChain({ current: 'link', failed: false, done: true }) setSuccess(t('chain_ready_to_sign')) if (signingTab && link) { signingTab.location.replace(link) signingTab = null // handed over to Skatteverket: don't close it below } else { signingTab?.close() signingTab = null } onRefreshSubmission() onChange?.() } catch (e) { signingTab?.close() setChain(prev => (prev ? { ...prev, failed: true } : prev)) if (!(e instanceof ChainFailed)) { setError(e instanceof Error ? getUserErrorMessage(e) : t('submit_failed')) } onRefreshSubmission() } finally { setActionLoading(null) } } // 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 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(errText(data) || t('xml_generate_failed')) } 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 ? getUserErrorMessage(e) : t('xml_download_failed')) } finally { setActionLoading(null) } } /** Advanced/recovery variant: submit the underlag without continuing the chain. */ const handleSubmit = async () => { setActionLoading('submit') setError(null) setSuccess(null) try { if (!(await ensureAgiGenerated())) return const ok = await runSubmitUnderlag() if (ok) setSuccess(t('underlag_accepted')) onRefreshSubmission() onChange?.() } catch (e) { setError(e instanceof Error ? getUserErrorMessage(e) : t('submit_failed')) } finally { setActionLoading(null) } } /** Advanced/recovery variant: create the signing link on its own. */ const handleCreateSigningLink = async () => { setActionLoading('granskning') setError(null) setSuccess(null) try { const link = await runCreateSigningLink() if (link !== null) setSuccess(t('signing_link_ready')) onRefreshSubmission() } catch (e) { setError(e instanceof Error ? getUserErrorMessage(e) : t('signing_link_failed')) } 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 || t('unlock_failed_status', { status: res.status })) return } setSuccess(t('unlock_success')) onRefreshSubmission() } catch (e) { setError(e instanceof Error ? getUserErrorMessage(e) : t('unlock_failed')) } finally { setActionLoading(null) } } /** * Post-signing recovery: 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 || t('kvittens_fetch_failed')) return } const kvittens = json.data?.kvittenser?.[0] if (kvittens?.uuidKvittens) { setSuccess(t('signed_success')) } else { setSuccess(t('no_kvittens_yet')) } onRefreshSubmission() onChange?.() } catch (e) { setError(e instanceof Error ? getUserErrorMessage(e) : t('check_status_failed')) } finally { setActionLoading(null) } } // ── Render branches ───────────────────────────────────────────── if (extensionDisabled) { return ( {t('title')}

{t('disabled_before')} SKATTEVERKET_ENABLED {t('disabled_after')}

) } if (loading) { return ( {t('title')} {t('loading_status')} ) } if (!status?.connected) { return ( {t('title')}

{t('connect_description')}

{!readOnly && ( )}
) } // 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') // Recovery states expose the advanced actions on their own: the stale-draft // and error-report guidance below reference them by name. const forcedAdvanced = draftIsStale || underlagRejected const advancedOpen = showAdvanced || forcedAdvanced const signedAtRaw = runSubmission?.signeradTid ?? agiSubmittedAt ?? null const signedAtText = signedAtRaw ? new Date(signedAtRaw).toLocaleString('sv-SE') : null const chainStepState = (step: ChainStep): 'done' | 'running' | 'failed' | 'upcoming' => { if (!chain) return 'upcoming' const idx = CHAIN_STEPS.indexOf(step) const currentIdx = CHAIN_STEPS.indexOf(chain.current) if (idx < currentIdx || (idx === currentIdx && chain.done)) return 'done' if (idx === currentIdx) return chain.failed ? 'failed' : 'running' return 'upcoming' } return ( {t('title')} {t('connected')} {!readOnly && ( )} {/* Filed: the terminal state deserves more than a gray status row. Kvittensnummer + signature metadata come from the run-scoped record; a run stamped only via agi_submitted_at (e.g. cron reconciliation with an evicted cache, or an original whose cached receipt a later correction has replaced) still gets the card, just without a number: better than showing another declaration's. */} {isSigned && (

{t('success_card_title', { period: prettyPeriod })}

{runSubmission?.kvittensnummer && (

{t('success_card_kvittens', { kvittens: runSubmission.kvittensnummer })}

)} {(runSubmission?.signeradAv || signedAtText) && (

{runSubmission?.signeradAv ? signedAtText ? t('success_card_signed_by_at', { name: runSubmission.signeradAv, date: signedAtText, }) : t('success_card_signed_by', { name: runSubmission.signeradAv }) : t('success_card_signed_at', { date: signedAtText ?? '' })}

)}
)} {/* 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 && (

{t('expired_banner_title')}

{t('expired_banner_description')}

)} {/* 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 && (

{t('missing_scope_title')}

{t('missing_scope_description')}

{t('open_settings')}
)} {/* 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 && (

{t('draft_locked_title')}

{t('draft_locked_description')}

{t('open_signing_link')}
)} {/* 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 && (

{t('stale_draft_title')}

{t('stale_draft_description', { generatedAt: agiGeneratedAt ? new Date(agiGeneratedAt).toLocaleString('sv-SE') : '', draftCreatedAt: submission?.updatedAt ? ` (${new Date(submission.updatedAt).toLocaleString('sv-SE')})` : '', })}{' '} {t('stale_draft_click')}{' '} {t('unlock_button')}{' '} {t('stale_draft_then')}{' '} {t('submit_button')}{' '} {t('stale_draft_to_sign')}

)} {/* 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 && (

{t('incorrect_data_title')}

{submission.meddelande || t('incorrect_data_description')}

{t('open_error_report')}
)} {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 && (
{/* Primary path: one click runs the whole filing chain. Hidden while a signing draft is open at SKV (the period is locked, so a resubmission would be refused): the signing-link card above is the CTA then, and the stale-draft recovery goes through the advanced actions per the guidance text. The XML download stays free for manual filing regardless. */}
{!awaitingSigning && ( )}
{chain && (
    {CHAIN_STEPS.map(step => { const state = chainStepState(step) return (
  1. {state === 'done' ? ( ) : state === 'running' ? ( ) : state === 'failed' ? ( ) : ( )} {t(`chain_step_${step}`)}
  2. ) })}
)} {/* Recovery/expert actions: each is one step of the chain above, for resuming after a partial failure. Auto-expanded when a recovery state (stale draft, rejected underlag) references them by name. */}
{!forcedAdvanced && ( )} {advancedOpen && (
{awaitingSigning && ( )}
)}
)} {!readOnly && !isSigned && !hasSkatteverket && ( {t('upgrade_note')} )}
) } function StatusRow({ ok, okText, pendingText, }: { ok: boolean okText: string pendingText: string }) { return (
{ok ? ( ) : ( )} {ok ? okText : pendingText}
) }