'use client' import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' import React, { useState, useEffect, useCallback } from 'react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { AlertCircle, CheckCircle2, Download, ExternalLink, Gavel, Link2, Link2Off, Loader2, FileCheck, Lock, Unlock, Send, ShieldAlert, Trash2, } from 'lucide-react' import type { VatDeclarationRutor, VatPeriodType } from '@/types' import { formatRedovisare, formatRedovisningsperiod } from '@/lib/skatteverket/format' import { runVatDeclarationChecks, type VatDeclarationCheck, } from '@/lib/reports/vat-declaration-checks' import type { RcBasisGap } from '@/lib/reports/rc-basis-gaps' import { formatDate } from '@/lib/utils' interface SkatteverketStatus { connected: boolean expired?: boolean canRefresh?: boolean scope?: string expiresAt?: string } /** * Codes from /lib/api-client.ts's SkatteverketAuthError that mean "the user * needs to reconnect with BankID before this action can succeed". When the API * returns one of these codes we flip the local status.expired flag so the * "Session utgången" badge + "Förnya session" button surface, even if the * upstream /status endpoint hasn't reflected the change yet. */ const AUTH_RECONNECT_CODES = new Set([ 'NOT_CONNECTED', 'SESSION_EXPIRED', 'REFRESH_EXHAUSTED', 'TOKEN_REVOKED', 'TOKEN_CORRUPTED', 'MISSING_SCOPE', ]) // Shape per Skatteverket Momsdeklaration v1.0.24 RAML // (kontrollResultat.resultat[].{kod, status, beskrivning}) interface KontrollResult { kod: string status: 'ERROR' | 'WARNING' beskrivning: string } interface SkatteverketPanelProps { periodType: VatPeriodType year: number period: number hasData: boolean /** * Calculated rutor for the current period. Used to run local pre-flight * checks before Skatteverket sees the payload — SKV only validates internal * arithmetic consistency, so we have to catch "ruta 30-32 present but * 20-24 empty" locally before letting the user submit. */ rutor?: VatDeclarationRutor | null } function formatAmount(amount: number): string { return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } const SKV_ENABLED = ENABLED_EXTENSION_IDS.has('skatteverket') export function SkatteverketPanel(props: SkatteverketPanelProps) { if (!SKV_ENABLED) return null return } function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: SkatteverketPanelProps) { const [status, setStatus] = useState(null) const [loading, setLoading] = useState(true) const [actionLoading, setActionLoading] = useState(null) const [error, setError] = useState(null) const [success, setSuccess] = useState(null) const [kontroller, setKontroller] = useState([]) const [signeringslank, setSigneringslank] = useState(null) const [submitted, setSubmitted] = useState<{ kvittensnummer?: string tidpunkt?: string } | null>(null) // Local sanity checks against the calculated declaration, run before any // SKV call. SKV's "OK" only confirms arithmetic — these checks confirm // the declaration looks plausible (no orphaned RC output, no missing // basis, no summaMoms drift). const localChecks: VatDeclarationCheck[] = rutor ? runVatDeclarationChecks(rutor) : [] const localErrors = localChecks.filter((c) => c.status === 'ERROR') const localBlocked = localErrors.length > 0 // Per-voucher RC basis gap detection — fetched whenever a RC_BASIS_MISSING // warning fires so we can show the user exactly which verifikationer are // missing the basbelopp pair and offer a one-click correction. const hasRcBasisWarning = localChecks.some((c) => c.code === 'RC_BASIS_MISSING') const [gaps, setGaps] = useState([]) const [gapsLoading, setGapsLoading] = useState(false) const [fixingId, setFixingId] = useState(null) const [gapSelections, setGapSelections] = useState< Record >({}) useEffect(() => { if (!hasRcBasisWarning) { setGaps([]) return } let cancelled = false setGapsLoading(true) fetch( `/api/reports/vat-declaration/rc-basis-gaps?periodType=${periodType}&year=${year}&period=${period}`, ) .then((r) => r.json()) .then((j) => { if (cancelled) return setGaps(j?.data?.gaps || []) }) .catch(() => { if (cancelled) return setGaps([]) }) .finally(() => { if (cancelled) return setGapsLoading(false) }) return () => { cancelled = true } }, [hasRcBasisWarning, periodType, year, period]) const handleFixGap = async (gap: RcBasisGap) => { const sel = gapSelections[gap.entryId] ?? { supplierType: 'eu_business', supplyType: 'service' as const } setFixingId(gap.entryId) setError(null) try { const res = await fetch('/api/reports/vat-declaration/rc-basis-gaps/fix', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ entryId: gap.entryId, supplierType: sel.supplierType, supplyType: sel.supplyType, }), }) const result = await res.json() if (!res.ok) { setError(result?.error || 'Kunde inte korrigera verifikationen') } else { setGaps((prev) => prev.filter((g) => g.entryId !== gap.entryId)) setSuccess( `Verifikation ${gap.voucherSeries}-${gap.voucherNumber} korrigerad. Storno + ny verifikation skapad. Ladda om sidan för att uppdatera rutorna.`, ) } } catch { setError('Kunde inte korrigera verifikationen') } finally { setFixingId(null) } } /** * Apply an API JSON error result. When the error indicates the SKV session * has expired/been revoked/lost scope, immediately reflect that in the * local status so the "Förnya session" CTA appears next to the message — * the user shouldn't have to wait for /status to catch up. */ const applyApiError = useCallback((result: { error?: string; code?: string } | null) => { if (!result?.error) return false setError(result.error) if (result.code && AUTH_RECONNECT_CODES.has(result.code)) { setStatus((prev) => prev ? { ...prev, expired: true } : prev) } return true }, []) const fetchStatus = useCallback(async () => { try { const res = await fetch('/api/extensions/ext/skatteverket/status') if (res.ok) { const data = await res.json() setStatus(data) } } catch { // Extension might not be enabled } finally { setLoading(false) } }, []) useEffect(() => { fetchStatus() // Check URL params for OAuth callback results const params = new URLSearchParams(window.location.search) if (params.get('skv_connected') === 'true') { setSuccess('Ansluten till Skatteverket') fetchStatus() // Clean URL const url = new URL(window.location.href) url.searchParams.delete('skv_connected') window.history.replaceState({}, '', url.toString()) } const skvError = params.get('skv_error') if (skvError) { setError(decodeURIComponent(skvError)) const url = new URL(window.location.href) url.searchParams.delete('skv_error') window.history.replaceState({}, '', url.toString()) } }, [fetchStatus]) const handleConnect = () => { window.location.href = '/api/extensions/ext/skatteverket/authorize' } const handleDisconnect = async () => { setActionLoading('disconnect') setError(null) try { const res = await fetch('/api/extensions/ext/skatteverket/disconnect', { method: 'POST', }) if (res.ok) { setStatus({ connected: false }) setSuccess(null) setKontroller([]) setSigneringslank(null) setSubmitted(null) } } catch { setError('Kunde inte koppla bort') } finally { setActionLoading(null) } } const handleValidate = async () => { if (localBlocked) { setError( 'Lokala kontroller hittade fel i bokföringen. Åtgärda dessa innan ' + 'du skickar till Skatteverket.', ) return } setActionLoading('validate') setError(null) setKontroller([]) try { const res = await fetch('/api/extensions/ext/skatteverket/declaration/validate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ periodType, year, period }), }) const result = await res.json() if (applyApiError(result)) { // surfaced + status updated; nothing more to do } else { const controls: KontrollResult[] = result.data?.kontrollResultat?.resultat || [] setKontroller(controls) if (controls.length === 0) { // SKV's OK only confirms arithmetic — it does NOT confirm that the // declaration is materially correct. We say so explicitly so the // user doesn't read this as a green light for actual filing. setSuccess( 'Skatteverket har inga tekniska invändningar mot deklarationen. ' + 'Kontrollera siffrorna i förhandsgranskningen innan du skickar in.', ) } else { const errors = controls.filter(k => k.status === 'ERROR') if (errors.length > 0) { setError(`${errors.length} valideringsfel hittades`) } else { setSuccess('Skatteverket har inga tekniska invändningar (med varningar)') } } } } catch { setError('Kunde inte validera deklarationen') } finally { setActionLoading(null) } } const handleSaveDraft = async () => { if (localBlocked) { setError( 'Lokala kontroller hittade fel i bokföringen. Åtgärda dessa innan ' + 'du sparar utkastet hos Skatteverket.', ) return } setActionLoading('draft') setError(null) try { const res = await fetch('/api/extensions/ext/skatteverket/declaration/draft', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ periodType, year, period }), }) const result = await res.json() if (applyApiError(result)) { // surfaced + status updated; nothing more to do } else { const controls: KontrollResult[] = result.data?.kontrollResultat?.resultat || [] setKontroller(controls) const errors = controls.filter(k => k.status === 'ERROR') if (errors.length === 0) { setSuccess('Utkast sparat i Eget utrymme hos Skatteverket') } else { setError(`Utkastet sparades men har ${errors.length} valideringsfel`) } } } catch { setError('Kunde inte spara utkast') } finally { setActionLoading(null) } } const handleLock = async () => { setActionLoading('lock') setError(null) try { const res = await fetch( `/api/extensions/ext/skatteverket/declaration/lock?redovisare=${encodeURIComponent( await getRedovisare() )}&redovisningsperiod=${getRedovisningsperiod()}`, { method: 'PUT' } ) const result = await res.json() if (applyApiError(result)) { // surfaced + status updated; nothing more to do } else if (result.data?.signeringsLank) { setSigneringslank(result.data.signeringsLank) setSuccess('Utkastet är låst. Öppna signeringslänken för att signera med BankID.') } } catch { setError('Kunde inte låsa utkastet') } finally { setActionLoading(null) } } const handleUnlock = async () => { setActionLoading('unlock') setError(null) try { const res = await fetch( `/api/extensions/ext/skatteverket/declaration/lock?redovisare=${encodeURIComponent( await getRedovisare() )}&redovisningsperiod=${getRedovisningsperiod()}`, { method: 'DELETE' } ) const result = await res.json() if (applyApiError(result)) { // surfaced + status updated; nothing more to do } else { setSigneringslank(null) setSuccess('Utkastet har låsts upp') } } catch { setError('Kunde inte låsa upp utkastet') } finally { setActionLoading(null) } } const handleCheckSubmitted = async () => { setActionLoading('check') setError(null) try { const res = await fetch( `/api/extensions/ext/skatteverket/declaration/submitted?redovisare=${encodeURIComponent( await getRedovisare() )}&redovisningsperiod=${getRedovisningsperiod()}` ) const result = await res.json() if (applyApiError(result)) { // surfaced + status updated; nothing more to do } else if (result.data) { setSubmitted(result.data) setSuccess('Deklarationen har lämnats in') } else { setSuccess('Ingen inlämnad deklaration hittades för denna period') } } catch { setError('Kunde inte kontrollera inlämningsstatus') } finally { setActionLoading(null) } } const handleDeleteDraft = async () => { setActionLoading('delete') setError(null) try { const res = await fetch( `/api/extensions/ext/skatteverket/declaration/draft?redovisare=${encodeURIComponent( await getRedovisare() )}&redovisningsperiod=${getRedovisningsperiod()}`, { method: 'DELETE' } ) if (res.status === 204 || res.ok) { setKontroller([]) setSigneringslank(null) setSuccess('Utkastet har raderats från Eget utrymme') } else { const result = await res.json().catch(() => ({})) if (!applyApiError(result)) { setError(`Kunde inte radera utkast (${res.status})`) } } } catch { setError('Kunde inte radera utkast') } finally { setActionLoading(null) } } const handleFetchDraft = async () => { setActionLoading('fetchDraft') setError(null) try { const res = await fetch( `/api/extensions/ext/skatteverket/declaration/draft?redovisare=${encodeURIComponent( await getRedovisare() )}&redovisningsperiod=${getRedovisningsperiod()}` ) const result = await res.json() if (applyApiError(result)) { // surfaced + status updated; nothing more to do } else if (!result.data) { setSuccess('Inget sparat utkast hittades för perioden') } else { const locked = result.data?.locked ? ' (låst)' : '' const summa = result.data?.momsuppgift?.summaMoms const summaLabel = summa !== undefined ? `, summaMoms = ${formatAmount(summa)}` : '' setSuccess(`Sparat utkast hittades${locked}${summaLabel}`) } } catch { setError('Kunde inte hämta utkast') } finally { setActionLoading(null) } } const handleFetchDecided = async () => { setActionLoading('fetchDecided') setError(null) try { const res = await fetch( `/api/extensions/ext/skatteverket/declaration/decided?redovisare=${encodeURIComponent( await getRedovisare() )}&redovisningsperiod=${getRedovisningsperiod()}` ) const result = await res.json() if (applyApiError(result)) { // surfaced + status updated; nothing more to do } else if (!result.data) { setSuccess('Inget beslut hittades för perioden') } else { const tid = result.data?.beslutadTidpunkt const tidLabel = tid ? ` (beslutad ${new Date(tid).toLocaleDateString('sv-SE')})` : '' setSuccess(`Beslut hittades${tidLabel}`) } } catch { setError('Kunde inte hämta beslutade uppgifter') } finally { setActionLoading(null) } } // Helper to get redovisare from settings const getRedovisare = async (): Promise => { const res = await fetch('/api/settings') const { data } = await res.json() if (!data?.org_number) throw new Error('Organisationsnummer saknas') return formatRedovisare(data.org_number, data.entity_type) } const getRedovisningsperiod = (): string => { return formatRedovisningsperiod(periodType, year, period) } if (loading) { return ( Kontrollerar Skatteverket-anslutning... ) } // Not connected if (!status?.connected) { return ( Skicka till Skatteverket {error && (
{error}
)}

Anslut till Skatteverket med BankID för att skicka momsdeklarationen direkt.

) } // Connected — show actions const hasErrors = kontroller.some(k => k.status === 'ERROR') const hasWarnings = kontroller.some(k => k.status === 'WARNING') return (
Skicka till Skatteverket
Ansluten {status.expired && ( <> Session utgången )}
{/* Messages */} {error && (
{error}
)} {success && !error && (
{success}
)} {/* Local pre-flight check results — surfaced separately from SKV's kontroller so the user knows these are Accounted's own sanity checks, not Skatteverket's. ERRORs block the submit/validate buttons. */} {localChecks.length > 0 && (

Lokala kontroller

{localChecks.map((c, i) => (
{c.status === 'ERROR' ? ( ) : ( )}
{c.code} {c.message}
))}
)} {/* Per-voucher RC basis gaps — concrete list of verifikationer that triggered RC_BASIS_MISSING, with a one-click korrigera action. */} {hasRcBasisWarning && (

Verifikationer som saknar basbelopp

{gapsLoading ? (
Söker berörda verifikationer...
) : gaps.length === 0 ? (

Inga verifikationer hittades. Bristen kan ligga utanför perioden eller i bokföring som inte är posted.

) : (
{gaps.map((gap) => { const sel = gapSelections[gap.entryId] ?? { supplierType: 'eu_business' as const, supplyType: 'service' as const, } return (

Verifikation {gap.voucherSeries}-{gap.voucherNumber} {' · '} {formatDate(gap.entryDate)}

{gap.description}

{gap.rcOutputAccount} har{' '} {gap.rcOutputAmount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2, })}{' '} kr fiktiv moms — saknar basbelopp{' '} {gap.expectedBasisAmount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2, })}{' '} kr

) })}
)}
)} {/* Validation results from Skatteverket */} {kontroller.length > 0 && (

Skatteverkets valideringsresultat

{kontroller.map((k, i) => (
{k.status === 'ERROR' ? ( ) : ( )}
{k.kod} {k.beskrivning}
))}
)} {/* Submitted confirmation */} {submitted && (

Inlämnad

{submitted.kvittensnummer && (

Kvittensnummer: {submitted.kvittensnummer}

)} {submitted.tidpunkt && (

Tidpunkt: {new Date(submitted.tidpunkt).toLocaleString('sv-SE')}

)}
)} {/* Signing link */} {signeringslank && (

Utkastet är låst och redo att signeras

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

)} {/* Forward-lifecycle buttons */}
{/* Read-only fetches from Skatteverket. */}
{/* Recovery / cleanup buttons. Always visible when connected so the user can back out of a locked or stale draft state without depending on local UI state surviving a reload. SKV returns 404/409 if the action isn't applicable; we surface that as an error message rather than hiding the button. */}
{/* Disconnect */}
) }