Files
accounted/components/salary/AGIPanel.tsx
T
d80103a2f5 fix(skatteverket): skattekonto-OCR is 13 digits, and the AGI panel stops guessing that you have not signed (#1888)
* fix(skatteverket): skattekonto-OCR is 13 digits, and the AGI panel stops guessing that you have not signed

Two reports from the same salary run (Fabian, Specific AI Sweden AB).

1. The payment file carried an OCR Skatteverket does not accept.
   generateSkattekontoOcr built the reference from the TEN-digit org number
   plus a Luhn check digit (11 digits). Skatteverket's reference is the
   TWELVE-digit identity plus a check digit: an organisationsnummer carries
   the "16" prefix, a personnummer its century. For 559547-0021 we emitted
   55954700211 where Skatteverket prints 1655954700217.

   The twelve-digit form is the same "redovisare" identity the AGI and moms
   APIs take, so it now goes through the shared toRedovisare12 converter
   instead of a second local rule: the payment file and the declaration it
   pays must not disagree about who the taxpayer is. That needs the entity
   type, which the route now reads alongside org_number.

   The route also prefers saldo.ocrNummer from the cached skattekonto
   snapshot over the derived value. It is Skatteverket's own answer for the
   account we actually sync, it covers identities the converter has no rule
   for (samordningsnummer, GD-nummer), and it covers the companies whose
   companies.org_number has drifted from company_settings.org_number.

2. AGI status stayed on "väntar på BankID-signatur i Mina Sidor" after the
   user had signed.
   Reading the kvittens needs a live Skatteverket session, and the personal
   token lives ~65 minutes, so by the time anyone signs in Mina Sidor the
   2-hourly kvittens cron finds a dead token and skips quietly. The panel
   kept asserting a state it could no longer observe.

   It now says so instead, and the reconnect action already on the panel is
   the fix: runPostConnectRefresh reconciles pending declarations on a fresh
   consent. sessionExpiredStatus also counts the needs_reconsent health flag,
   which a cron can set while the access token is still inside its hour;
   without it the panel reported a dead connection as healthy.

   Background reconciliation without a reconnect needs the läsombud grant,
   which is a registration decision and not part of this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(skatteverket): say why the entity_type collapse in the payment-file route is total

companies.entity_type is NOT NULL with CHECK IN ('enskild_firma',
'aktiebolag'), so the ternary cannot silently mis-tag an enskild firma as
a legal entity and give a personnummer the "16" prefix. Two review bots
read it as an unguarded default; write down the constraint that makes it
safe instead of leaving the next reader to re-derive it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(decisions): record why the cached skattekonto OCR needs no freshness gate

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 14:25:00 +02:00

1404 lines
56 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
AlertCircle,
CheckCircle2,
Circle,
Download,
ExternalLink,
Link2,
Link2Off,
Loader2,
Lock,
Send,
Unlock,
} from 'lucide-react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { DetailSection } from '@/components/ui/detail-section'
import { HelpPopover } from '@/components/ui/help-popover'
import { AttnLine } from '@/components/ui/attn-line'
import { Skeleton } from '@/components/ui/skeleton'
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'
import { cn, formatDateTime } from '@/lib/utils'
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
/** Persisted health flag: a cron hit a terminal auth state on this token. */
needsReconsent?: 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<ConnectionStatus | null>(null)
const [kontroller, setKontroller] = useState<KontrollFinding[]>([])
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [chain, setChain] = useState<ChainProgress | null>(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)
// Dismissal for the kvittens-scope notice, keyed by employer plus the exact
// granted scope string. The employer keeps dismissals from leaking across
// companies on a shared browser (tokens are per company, so each company's
// grant is its own question); the scope string means a reconnect that comes
// back with the same grant (the scope not yet registered on Skatteverket's
// application) keeps the dismissal, so the notice cannot become an
// un-clearable reconnect loop (#1010), while a new grant re-evaluates from
// scratch.
const [kvittensNoticeDismissed, setKvittensNoticeDismissed] = useState(false)
const grantedScopeString = typeof status?.scope === 'string' ? status.scope : null
const kvittensNoticeKey = grantedScopeString
? `agi-kvittens-scope-notice:${arbetsgivare}:${grantedScopeString}`
: null
useEffect(() => {
if (!kvittensNoticeKey) return
try {
setKvittensNoticeDismissed(localStorage.getItem(kvittensNoticeKey) === 'dismissed')
} catch {
setKvittensNoticeDismissed(false)
}
}, [kvittensNoticeKey])
const dismissKvittensNotice = useCallback(() => {
setKvittensNoticeDismissed(true)
if (!kvittensNoticeKey) return
try {
localStorage.setItem(kvittensNoticeKey, 'dismissed')
} catch {
// Best effort: the state update alone hides it for this mount.
}
}, [kvittensNoticeKey])
// "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 && !next.needsReconsent
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<Window | null>(null)
const watchTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const delayedRefetchRef = useRef<ReturnType<typeof setTimeout> | 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<boolean | null>(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<ReturnType<typeof setTimeout>[]>([])
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<boolean> => {
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<boolean> {
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<boolean> {
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<string | null> {
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 (
<DetailSection kicker={t('title')}>
<p className="text-sm text-muted-foreground">
{t('disabled_before')}
<code className="mx-1 rounded-sm bg-muted px-1 py-0.5 text-xs">SKATTEVERKET_ENABLED</code>
{t('disabled_after')}
</p>
</DetailSection>
)
}
if (loading) {
return (
<DetailSection kicker={t('title')}>
<div role="status" aria-label={t('loading_status')} className="space-y-2">
<Skeleton className="h-4 w-64" />
<Skeleton className="h-4 w-48" />
</div>
</DetailSection>
)
}
if (!status?.connected) {
return (
<DetailSection kicker={t('title')}>
<p className="text-sm text-muted-foreground">{t('connect_description')}</p>
{!readOnly && (
<div className="mt-3 flex justify-end">
<Button onClick={handleConnect} disabled={connecting}>
<Link2 className="mr-2 h-4 w-4" />
{connecting ? t('connect_waiting') : t('connect_button')}
</Button>
</div>
)}
</DetailSection>
)
}
// Tokens issued before an AGI scope was added to DEFAULT_SCOPES will 403 at
// submission time: surface that proactively so the user reconnects before
// hitting the deadline rather than at it. The two scopes back different
// steps and get different treatments: `agd` (inlämning) fails already at
// submit, is proven grantable, and keeps the hard reconnect nudge. A token
// missing only `agdredovisningperiod` (hantera) sails through submit and
// signing and dies on "Hämta kvittens", but until Skatteverket's application
// registration carries that scope a reconnect mints the same grant again
// (SKV silently drops unregistered scope names), so its notice must be
// dismissible rather than a demand no reconnect can clear (#1010).
const grantedScopes =
typeof status?.scope === 'string' ? status.scope.split(/\s+/).filter(Boolean) : null
const missingAgdScope = grantedScopes !== null && !grantedScopes.includes('agd')
const missingKvittensScope =
grantedScopes !== null && !missingAgdScope && !grantedScopes.includes('agdredovisningperiod')
// Expired session: the token row exists (so status.connected is true) but
// the access token is past expiry, has no refresh token or has burned
// through its 10-refresh budget, or a cron already parked it as
// needs_reconsent. The only fix is a fresh BankID round-trip. The
// needs_reconsent flag has to count on its own: it is set on terminal auth
// errors that can land while the access token is still inside its hour, and
// without it the panel reports a dead connection as healthy.
const sessionExpiredStatus =
status?.expired === true || status?.canRefresh === false || status?.needsReconsent === true
// One attention sentence per section (convention 6). The expired session
// outranks the missing inlämning scope: nothing can be filed until the
// user has reconnected, and the fresh grant re-evaluates the scope. When
// both hold, the scope notice drops to a muted line so it is still said.
const attn: 'expired' | 'scope' | null =
readOnly ? null : sessionExpiredStatus ? 'expired' : missingAgdScope ? 'scope' : null
// 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 ?? runSubmission?.submittedAt ?? agiSubmittedAt ?? null
const signedAtText = signedAtRaw ? formatDateTime(signedAtRaw) : null
// A signed record without Skatteverket's signeradTid carries our
// reconciliation-time stamp instead (an upper bound on the signing moment,
// see agi-kvittens-reconcile.ts): say so rather than presenting it as the
// legal signing time. Without any record we cannot tell and print the run
// stamp as before.
const signedAtEstimated =
runSubmission?.status === 'signed' &&
(runSubmission.submittedAtEstimated === true || !runSubmission.signeradTid)
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'
}
const reconnectLabel = connecting ? t('connect_waiting') : t('reconnect_button')
return (
<DetailSection
kicker={t('title')}
help={<HelpPopover>{t('granskningsunderlag_gloss')}</HelpPopover>}
aside={
// Connected is the normal state: muted text, no chip (convention 5).
// Disconnect is the quiet action beside it.
<span className="flex items-center gap-3 text-[11px] text-muted-foreground">
<span>{t('connected')}</span>
{!readOnly && (
<button
type="button"
onClick={handleDisconnect}
disabled={actionLoading === 'disconnect' || connecting}
className="inline-flex items-center gap-1 underline decoration-border underline-offset-4 transition-colors duration-150 hover:text-destructive disabled:cursor-not-allowed disabled:opacity-50"
title={t('disconnect_title')}
>
{actionLoading === 'disconnect' && <Loader2 className="h-3 w-3 animate-spin" />}
{t('disconnect_button')}
</button>
)}
</span>
}
>
<div className="space-y-4">
{/* Filed: the terminal state, stated first. Kvittensnummer + signature
metadata come from the run-scoped record, which /agi/status serves
from the in-flight cache or, once the kvittens reconciliation has
deleted that cache, from agi_declarations (#1597). A run stamped
only via agi_submitted_at (an original whose receipt a later
correction has replaced) still gets the lines, just without a
number: better than showing another declaration's. */}
{isSigned && (
<div className="space-y-1 text-sm">
<p className="font-medium">{t('success_card_title', { period: prettyPeriod })}</p>
{runSubmission?.kvittensnummer && (
<p className="text-muted-foreground tabular-nums">
{t('success_card_kvittens', { kvittens: runSubmission.kvittensnummer })}
</p>
)}
{(runSubmission?.signeradAv || signedAtText) && (
<p className="text-muted-foreground">
{runSubmission?.signeradAv
? signedAtText
? t(
signedAtEstimated
? 'success_card_signed_by_at_estimated'
: 'success_card_signed_by_at',
{ name: runSubmission.signeradAv, date: signedAtText },
)
: t('success_card_signed_by', { name: runSubmission.signeradAv })
: t(
signedAtEstimated
? 'success_card_signed_at_estimated'
: 'success_card_signed_at',
{ date: signedAtText ?? '' },
)}
</p>
)}
</div>
)}
{attn === 'expired' && (
<AttnLine
action={{
label: reconnectLabel,
onClick: () => {
if (!connecting) handleConnect()
},
}}
>
{t('expired_banner_title')}. {t('expired_banner_description')}
</AttnLine>
)}
{/* Missing-scope nudges: proactive, before the user hits a 403
invalid_scope. The agd scope was added after some users had
already connected, so their stored token grants moms/skattekonto
but not AGI: that one is the hard nudge (the attention line, or a
muted line when the expired session already holds it). The
kvittens scope only breaks the final receipt fetch and may not be
grantable yet, so its notice is softer and dismissible. */}
{attn === 'scope' && (
<AttnLine action={{ label: t('open_settings'), href: '/settings/tax' }}>
{t('missing_scope_title')}. {t('missing_scope_description')}
</AttnLine>
)}
{missingAgdScope && !readOnly && attn !== 'scope' && (
<p className="text-xs text-muted-foreground">
{t('missing_scope_title')}. {t('missing_scope_description')}{' '}
<a href="/settings/tax" className="underline underline-offset-2 hover:text-foreground">
{t('open_settings')}
</a>
</p>
)}
{missingKvittensScope && !kvittensNoticeDismissed && !readOnly && (
<p className="text-xs text-muted-foreground">
<span className="text-foreground">{t('kvittens_scope_title')}.</span>{' '}
{t('kvittens_scope_description')}{' '}
<a href="/settings/tax" className="underline underline-offset-2 hover:text-foreground">
{t('open_settings')}
</a>{' '}
<button
type="button"
onClick={dismissKvittensNotice}
className="underline underline-offset-2 hover:text-foreground"
>
{t('kvittens_scope_dismiss')}
</button>
</p>
)}
{/* Status summary: one line per step, hairlines between. */}
<div className="divide-y divide-border text-sm">
<StatusRow
ok={!!agiGeneratedAt}
okText={agiGeneratedAt ? t('file_generated', { date: formatDateTime(agiGeneratedAt) }) : ''}
pendingText={t('file_not_generated')}
/>
<StatusRow
ok={isSigned}
okText={
runSubmission?.kvittensnummer
? t('submitted_with_kvittens', { kvittens: runSubmission.kvittensnummer })
: agiSubmittedAt
? t('submitted_at', { date: formatDateTime(agiSubmittedAt) })
: t('submitted')
}
pendingText={
awaitingSigning
? draftIsStale
? t('pending_stale_draft')
: sessionExpiredStatus
? t('pending_signature_unverifiable')
: t('pending_awaiting_signature')
: underlagSubmitted
? t('pending_underlag_submitted')
: t('pending_not_submitted')
}
/>
</div>
{/* 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 && (
<div className="text-sm">
<p className="font-medium">{t('draft_locked_title')}</p>
<p className="mt-1 text-xs text-muted-foreground">{t('draft_locked_description')}</p>
<a
href={submission.signeringslank}
target="_blank"
rel="noreferrer"
className="mt-2 inline-flex items-center gap-1 font-medium underline underline-offset-2 hover:opacity-80"
>
{t('open_signing_link')} <ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
)}
{/* 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 && (
<div className="text-sm">
<p className="font-medium">{t('stale_draft_title')}</p>
<p className="mt-1 text-xs text-muted-foreground">
{t('stale_draft_description', {
generatedAt: agiGeneratedAt ? formatDateTime(agiGeneratedAt) : '',
draftCreatedAt: submission?.updatedAt
? ` (${formatDateTime(submission.updatedAt)})`
: '',
})}{' '}
{t('stale_draft_click')}{' '}
<span className="font-medium">{t('unlock_button')}</span>{' '}
{t('stale_draft_then')}{' '}
<span className="font-medium">{t('submit_button')}</span>{' '}
{t('stale_draft_to_sign')}
</p>
</div>
)}
{/* 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 && (
<div className="text-sm">
<p className="font-medium text-destructive">{t('incorrect_data_title')}</p>
<p className="mt-1 text-xs text-muted-foreground">
{submission.meddelande || t('incorrect_data_description')}
</p>
<a
href={submission.signeringslank}
target="_blank"
rel="noreferrer"
className="mt-2 inline-flex items-center gap-1 font-medium text-destructive underline underline-offset-2 hover:opacity-80"
>
{t('open_error_report')} <ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
)}
{/* Skatteverket's kontrollresultat findings: one line per finding,
severity carried by the text tone (terracotta for STOPP, ochre
for ärende/warning), no box. */}
{kontroller.length > 0 && (
<div className="divide-y divide-border text-xs">
{kontroller.map((k, i) => (
<div
key={i}
className={cn(
'flex items-start gap-2 py-2',
k.status === 'STOPP' ? 'text-destructive' : 'text-attn',
)}
>
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
{k.kod && <span className="font-mono">{k.kod} </span>}
{k.uppgiftsTyp && <span className="text-muted-foreground">[{k.uppgiftsTyp}{k.specifikationsnummer ? ` #${k.specifikationsnummer}` : ''}] </span>}
{k.beskrivning}
</span>
</div>
))}
</div>
)}
{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) ||
sessionExpiredStatus
return (
<div className="text-sm text-destructive">
<p>
<AlertCircle className="mr-1 inline h-3.5 w-3.5" />
{error}
</p>
{sessionExpired && !readOnly && (
<div className="mt-2">
<Button size="sm" variant="outline" onClick={handleConnect} disabled={connecting}>
<Link2 className="mr-2 h-3.5 w-3.5" />
{reconnectLabel}
</Button>
</div>
)}
</div>
)
})()}
{success && !error && (
<p className="text-sm">
<CheckCircle2 className="mr-1 inline h-3.5 w-3.5 text-success" />
{success}
</p>
)}
{!readOnly && !isSigned && (
<div className="space-y-3">
{/* Live progress of the one-click chain: a flat step list that
sits with the status above, so the action row does not move
while it runs. */}
{chain && (
<ol className="space-y-1 text-xs">
{CHAIN_STEPS.map(step => {
const state = chainStepState(step)
return (
<li key={step} className="flex items-center gap-2">
{state === 'done' ? (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
) : state === 'running' ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
) : state === 'failed' ? (
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-destructive" />
) : (
<Circle className="h-3.5 w-3.5 shrink-0 text-muted-foreground/50" />
)}
<span className={state === 'upcoming' ? 'text-muted-foreground' : ''}>
{t(`chain_step_${step}`)}
</span>
</li>
)
})}
</ol>
)}
{/* The one action row. 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 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. The advanced toggle sits quietly at the left of
the row; its actions fold out beneath. */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
{!forcedAdvanced && (
<button
type="button"
onClick={() => setShowAdvanced(v => !v)}
className="text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
{advancedOpen ? t('advanced_hide') : t('advanced_show')}
</button>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={handleDownloadXml}
disabled={actionLoading === 'download'}
title={t('download_xml_title')}
>
{actionLoading === 'download' ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<Download className="mr-2 h-3.5 w-3.5" />
)}
{t('download_xml_button')}
</Button>
{!awaitingSigning && (
<Button
onClick={handleSubmitChain}
disabled={actionLoading !== null || !hasSkatteverket}
>
{actionLoading === 'chain' ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
{t('chain_button')}
</Button>
)}
</div>
</div>
{/* 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. */}
{advancedOpen && (
<div className="flex flex-wrap justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={handleSubmit}
disabled={actionLoading !== null || !hasSkatteverket}
>
{actionLoading === 'submit' ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<Send className="mr-2 h-3.5 w-3.5" />
)}
{t('submit_button')}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleCreateSigningLink}
disabled={actionLoading !== null || !underlagSubmitted}
>
{actionLoading === 'granskning' ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<Lock className="mr-2 h-3.5 w-3.5" />
)}
{t('signing_link_button')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleCheckSubmitted}
disabled={actionLoading !== null}
>
{actionLoading === 'check' ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<Download className="mr-2 h-3.5 w-3.5" />
)}
{t('check_kvittens_button')}
</Button>
{awaitingSigning && (
<Button
size="sm"
variant="ghost"
onClick={handleUnlock}
disabled={actionLoading !== null}
>
{actionLoading === 'unlock' ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<Unlock className="mr-2 h-3.5 w-3.5" />
)}
{t('unlock_button')}
</Button>
)}
</div>
)}
</div>
)}
{!readOnly && !isSigned && !hasSkatteverket && (
<UpgradeNote>{t('upgrade_note')}</UpgradeNote>
)}
</div>
</DetailSection>
)
}
function StatusRow({
ok,
okText,
pendingText,
}: {
ok: boolean
okText: string
pendingText: string
}) {
return (
<div className="flex items-start gap-2 py-2">
{ok ? (
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-success" />
) : (
<Link2Off className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
)}
<span className="text-muted-foreground">{ok ? okText : pendingText}</span>
</div>
)
}