Files
accounted/components/salary/AGIPanel.tsx
T
Jakob WennbergandClaude Opus 4.7 20989379bb feat(sandbox,branding): prod-parity demo with AI gating + accounted rebrand (#585)
* feat(sandbox,branding): prod-parity demo with AI gating + accounted rebrand

Sandbox now ships with seeded suppliers, supplier invoices, an asset,
a verified agent_profile, and pending operations so the demo company
exercises every prod surface. Server-side `guardSandbox()` short-
circuits any AI or paid-external API call (Bedrock chat/composer,
Resend invoice send, Riksbanken FX, VIES, etc.) and the AgentSheet
swaps in a SandboxAgentPreview that explains what's gated and offers
a register CTA. DashboardContent no longer mounts the
NewUserChecklist when the agent is already built, fixing the path
that let sandbox users still trigger /onboarding/agent.

Visible branding flips from Gnubok to Accounted: new BrandWordmark
component (Hedvig Letters Serif 700), new app/icon.png + PWA icons
generated from the accounted icon, default appName updated. URLs,
header names, API key prefixes, hostnames, and event/cookie/
localStorage keys keep `gnubok` — the rebrand is visual only.

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

* fix(sandbox): hardcode supplier-invoice arrival numbers in seed

get_next_arrival_number is MAX(arrival_number) + 1 against the same
table we're about to insert into. Calling it twice before either row
lands made both calls return 1, which then violated the
(company_id, arrival_number) unique index — POST /api/sandbox/seed
500'd on first sandbox start.

The seeded company is brand new in this branch so 1 and 2 are
guaranteed unused; hardcoding side-steps the race entirely.

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

* fix(sandbox): set paid_amount=0 on unpaid supplier invoice

PostgREST normalizes the column set across rows in a bulk insert, so
the second supplier invoice (Espresso House, status=registered) was
being sent with paid_amount=null because the first row (Telia, paid)
set it. supplier_invoices.paid_amount is NOT NULL DEFAULT 0; the
default only kicks in when the column is *absent* from the payload,
not when it's explicitly null. Set it inline to side-step the
normalization.

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

* fix(sandbox): set actor_type=agent_chat on seeded pending_operations

pending_operations only allows user-scoped INSERTs via the
`pending_operations_chat_insert` policy, which requires
actor_type='agent_chat' alongside auth.uid()=user_id +
company membership. The seed was inserting with the default
actor_type='user', tripping the RLS check.

Also lift risk_level from preview_data (where it was unused) onto
the row itself, matching the column added in
20260430120000_pending_operations_actor_and_risk.

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

* fix(pr-review): address PR #585 review feedback

Fixes called out by the core-only CI check, Greptile, and the
compliance + Swedish-accounting bots:

- AGI Programnamn pinned back to 'gnubok' (CI blocker). The XML
  Skatteverket receives must keep the stable software identifier
  regardless of the visual rebrand — same rule as the v1 health
  endpoint's `service: 'gnubok'` literal.
- handleCreateAccount in SandboxAgentPreview + ChatEmptyState now
  wraps signOut() in try/catch so a transient Supabase failure
  doesn't strand the user on a dead button (greptile P2 × 2).
- /api/currency/rate hard-fails on missing companyId instead of
  conditionally skipping the sandbox guard (greptile P2 / compliance
  V8.2.1).
- topUpSandboxAdditions now delegates to ensureSandboxAgentProfile;
  the assistant persona lives in exactly one place across the seed,
  layout backfills, and top-up path (greptile P2 outside-diff /
  compliance SOC2 CC6.1).
- ensureSandboxAgentProfile drops the userId param and sets
  verified_by_user_id to NULL — synthetic seed data should not
  attribute verification to a real user (compliance V8.2.1 /
  GDPR Art. 25(2)). Errors now logged via the structured logger
  instead of being silently swallowed (V16).
- Sandbox seed swaps real-world company names (Telia, Espresso
  House) for clearly-synthetic Demo-prefixed brands using the
  5559... documentation org-number range (compliance A.8.33).
  Asset cost bumped 24 000 → 35 000 SEK so the demo clears the
  förbrukningsinventarier threshold and illustrates capitalization
  unambiguously (swedish-asset-accounting).
- Representation pending-operation preview corrected: VAT label
  fixed from 6% → 12%, and input VAT split between the avdragsgill
  (2641) and ej-avdragsgill (5811) portions to match
  swedish-vat / ML 8 kap rules (swedish-vat).

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

* fix(pr-review): seed preview consistency + AGI Programnamn constant

Two last review-bot items before merge:

- Sandbox seed: the representation pending-operation preview was
  splitting the 240 SEK café meal 60/180 between 5810 and 5811,
  which is wrong for a single attendee under the 300 SEK / person
  avdragsgill cap (ML 8 kap) — the entire amount is fully
  avdragsgill in that case. Collapse the preview to a single 5810
  + 2641 + 2440 entry so it matches the supplier_invoice_items row
  1:1 and stops teaching demo users an incorrect bookkeeping
  pattern.
- Hoist the AGI Programnamn 'gnubok' literal into a named constant
  with a comment pointing to potential future Skatteverket vendor
  registration (per the swedish-compliance bot's nit).

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

* fix(sandbox): avoid BFL duplicate-verification on pending op + fix VAT cap comment

Swedish-compliance bot caught two final nits:

- The pending operation for the Demokafé representation was using the
  same supplier_invoice_number as the already-seeded supplier_invoices
  row (88245). If the sandbox user approved the staged operation, the
  insert would have created (or attempted) a duplicate verification —
  BFL 5 kap. requires each affärshändelse be recorded exactly once.
  Swap the staged operation's invoice number to a distinct value
  (INKOMMANDE-2026-001) so approval cleanly creates a new row.
- The preview comment described the 300 SEK threshold as an
  "avdragsgill cap". The actual rule (ML 8 kap. 9 §) caps the
  deductible VAT at 25 % × 300 SEK × antal_personer = 75 SEK per
  person — the 300 SEK is the tax base, not the total. Math here is
  correct either way, but the comment now states the correct formula
  so future seed edits don't propagate the wrong understanding.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 13:19:41 +02:00

853 lines
32 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,
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'
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
}
/** 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 [extensionDisabled, setExtensionDisabled] = useState(false)
const [status, setStatus] = useState<ConnectionStatus | null>(null)
const [submission, setSubmission] = useState<SubmissionState | 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 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])
// 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 = []
}
}, [])
/**
* Background-poll /agi/kvittenser at 30s, 2 min, and 5 min after the user
* receives a signing link. The kvittenser handler in the extension stamps
* salary_runs.agi_submitted_at when it observes a uuidKvittens, so this
* gives us a high-probability confirmation without depending on the user
* returning to the panel and clicking "Hämta kvittens" — which is critical
* for the audit trail (BFL 5 kap / BFNAR 2013:2): a NULL agi_submitted_at
* after a real filing would misrepresent the behandlingshistorik.
*
* Each poll silently refreshes local submission state on success and
* stops scheduling further polls once a kvittens is observed.
*/
const scheduleKvittensPolls = useCallback(() => {
for (const t of kvittensTimers.current) clearTimeout(t)
kvittensTimers.current = []
const poll = async () => {
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/agi/kvittenser?arbetsgivare=${encodeURIComponent(arbetsgivare)}&period=${period}`,
)
if (!res.ok) return
const json = await res.json()
const signed = !!json.data?.kvittenser?.[0]?.uuidKvittens
await fetchSubmission()
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 = []
onChange?.()
}
} catch {
// Silent: this is a background helper. The "Hämta kvittens" button
// remains the explicit recovery path.
}
}
kvittensTimers.current.push(setTimeout(poll, 30_000))
kvittensTimers.current.push(setTimeout(poll, 120_000))
kvittensTimers.current.push(setTimeout(poll, 300_000))
}, [arbetsgivare, period, fetchSubmission, onChange])
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.
*/
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 (
<Card>
<CardHeader>
<CardTitle className="text-base">Arbetsgivardeklaration (AGI)</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
<div className="flex items-start gap-2">
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0" />
<p>
Skatteverket-integrationen är inaktiverad i denna miljö. Aktivera
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-xs">SKATTEVERKET_ENABLED</code>
för att skicka AGI direkt till Skatteverket.
</p>
</div>
</CardContent>
</Card>
)
}
if (loading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Arbetsgivardeklaration (AGI)</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
<Loader2 className="mr-2 inline h-4 w-4 animate-spin" /> Hämtar Skatteverket-status...
</CardContent>
</Card>
)
}
if (!status?.connected) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Arbetsgivardeklaration (AGI)</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
Anslut till Skatteverket med BankID för att skicka AGI direkt från accounted.
</p>
{!readOnly && (
<Button onClick={handleConnect}>
<Link2 className="mr-2 h-4 w-4" />
Anslut med BankID
</Button>
)}
</CardContent>
</Card>
)
}
const subState = submission?.status
const awaitingSigning = subState === 'awaiting_signing'
const underlagSubmitted = subState === 'underlag_submitted'
const underlagRejected = subState === 'underlag_rejected'
const isSigned = subState === 'signed' || !!agiSubmittedAt
// 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 (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between text-base">
<span>Arbetsgivardeklaration (AGI)</span>
<span className="flex items-center gap-2 text-xs font-normal text-muted-foreground">
<span className="flex items-center gap-1">
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />
Ansluten
</span>
{!readOnly && (
<button
type="button"
onClick={handleDisconnect}
disabled={actionLoading === 'disconnect'}
className="inline-flex items-center gap-1 rounded border border-border px-1.5 py-0.5 text-[11px] font-normal text-muted-foreground transition-colors hover:border-destructive/50 hover:text-destructive disabled:cursor-not-allowed disabled:opacity-50"
title="Koppla bort anslutningen mot Skatteverket"
>
{actionLoading === 'disconnect' ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<PlugZap className="h-3 w-3" />
)}
Koppla bort
</button>
)}
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 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 && (
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 dark:border-amber-900/40 dark:bg-amber-900/20">
<p className="text-sm font-medium">Anslutningen mot Skatteverket har gått ut</p>
<p className="mt-1 text-xs text-muted-foreground">
Logga in med BankID igen för att kunna skicka AGI.
</p>
<Button size="sm" variant="outline" className="mt-2" onClick={handleConnect}>
<Link2 className="mr-1.5 h-3.5 w-3.5" />
Återanslut med BankID
</Button>
</div>
)}
{/* 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 && (
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 dark:border-amber-900/40 dark:bg-amber-900/20">
<p className="text-sm font-medium">
Anslutningen mot Skatteverket saknar behörighet för Arbetsgivardeklaration
</p>
<p className="mt-1 text-xs text-muted-foreground">
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.
</p>
<a
href="/settings/tax"
className="mt-2 inline-flex items-center gap-1 text-sm font-medium hover:underline"
>
Öppna inställningar <ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
)}
{/* Status summary */}
<div className="space-y-1.5 text-sm">
<StatusRow
ok={!!agiGeneratedAt}
okText={agiGeneratedAt ? `AGI-fil genererad ${new Date(agiGeneratedAt).toLocaleString('sv-SE')}` : ''}
pendingText="AGI-fil har inte genererats ännu."
/>
<StatusRow
ok={isSigned}
okText={
submission?.kvittensnummer
? `Skickad till Skatteverket — kvittens ${submission.kvittensnummer}`
: agiSubmittedAt
? `Skickad till Skatteverket ${new Date(agiSubmittedAt).toLocaleString('sv-SE')}`
: 'Skickad'
}
pendingText={
awaitingSigning
? 'Granskningsunderlag klart — väntar på BankID-signatur i Mina Sidor.'
: underlagSubmitted
? 'Underlag inläst hos Skatteverket. Skapa granskningsunderlag för att gå vidare till signering.'
: 'Inte skickad till Skatteverket ännu. Deadline: 12:e i månaden efter utbetalning (17:e i januari/augusti för arbetsgivare vars sammanlagda lönesumma understiger 40 MSEK per år).'
}
/>
</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 && (
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 dark:border-amber-900/40 dark:bg-amber-900/20">
<p className="text-sm font-medium">Utkastet är låst och redo att signeras</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Öppna länken nedan och signera med BankID på Skatteverkets sida.
</p>
<a
href={submission.signeringslank}
target="_blank"
rel="noreferrer"
className="mt-2 inline-flex items-center gap-1 text-sm font-medium text-amber-900 hover:underline dark:text-amber-200"
>
Öppna signeringslänk <ExternalLink className="h-3.5 w-3.5" />
</a>
</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="rounded-md border border-destructive/40 bg-destructive/5 p-3">
<p className="text-sm font-medium text-destructive">
Felaktiga underlag — granskningsunderlag kunde inte signeras
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{submission.meddelande || 'Skatteverket avvisade underlaget. Öppna felrapporten för detaljer.'}
</p>
<a
href={submission.signeringslank}
target="_blank"
rel="noreferrer"
className="mt-2 inline-flex items-center gap-1 text-sm font-medium text-destructive hover:underline"
>
Öppna felrapport hos Skatteverket <ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
)}
{kontroller.length > 0 && (
<div className="space-y-1 rounded-md border bg-muted/30 p-2.5">
{kontroller.map((k, i) => (
<div
key={i}
className={`flex items-start gap-2 text-xs ${
k.status === 'STOPP' ? 'text-destructive' : 'text-amber-700 dark:text-amber-400'
}`}
>
<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) ||
status?.expired === true ||
status?.canRefresh === false
return (
<div className="rounded-md bg-destructive/10 p-2.5 text-sm text-destructive">
<AlertCircle className="mr-1 inline h-3.5 w-3.5" />
{error}
{sessionExpired && !readOnly && (
<div className="mt-2">
<Button size="sm" variant="outline" onClick={handleConnect}>
<Link2 className="mr-1.5 h-3.5 w-3.5" />
Återanslut med BankID
</Button>
</div>
)}
</div>
)
})()}
{success && !error && (
<div className="rounded-md bg-emerald-50 p-2.5 text-sm text-emerald-900 dark:bg-emerald-900/20 dark:text-emerald-300">
<CheckCircle2 className="mr-1 inline h-3.5 w-3.5" />
{success}
</div>
)}
{!readOnly && !isSigned && (
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={handleSubmit}
disabled={actionLoading === 'submit'}
>
{actionLoading === 'submit' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Send className="mr-1.5 h-3.5 w-3.5" />
)}
Skicka in underlag
</Button>
<Button
size="sm"
onClick={handleCreateSigningLink}
disabled={actionLoading === 'granskning' || !underlagSubmitted}
>
{actionLoading === 'granskning' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Lock className="mr-1.5 h-3.5 w-3.5" />
)}
Skapa signeringslänk
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleCheckSubmitted}
disabled={actionLoading === 'check'}
>
{actionLoading === 'check' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Download className="mr-1.5 h-3.5 w-3.5" />
)}
Hämta kvittens
</Button>
{awaitingSigning && (
<Button
size="sm"
variant="ghost"
onClick={handleUnlock}
disabled={actionLoading === 'unlock'}
>
{actionLoading === 'unlock' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Unlock className="mr-1.5 h-3.5 w-3.5" />
)}
Lås upp
</Button>
)}
</div>
)}
</CardContent>
</Card>
)
}
function StatusRow({
ok,
okText,
pendingText,
}: {
ok: boolean
okText: string
pendingText: string
}) {
return (
<div className="flex items-center gap-2">
{ok ? (
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
) : (
<Link2Off className="h-4 w-4 text-muted-foreground" />
)}
<span className="text-muted-foreground">{ok ? okText : pendingText}</span>
</div>
)
}