'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' import { useCapability } from '@/contexts/CompanyContext' import { CAPABILITY } from '@/lib/entitlements/keys' import { UpgradeNote } from '@/components/billing/UpgradeNote' 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 hasSkvCapability = useCapability(CAPABILITY.skatteverket) 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 = () => { // return_to brings the user back to the momsdeklaration after the BankID // round-trip; the authorize route's default otherwise lands on the report // library. The callback appends skv_connected/skv_error itself. window.location.href = '/api/extensions/ext/skatteverket/authorize?return_to=' + encodeURIComponent('/reports/vat-declaration') } 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... ) } // Paywall: direct API submission is the paid convenience; manual filing at // skatteverket.se stays free and is described in the card above this panel. // Rendered BEFORE the connected check so a company that connected during // trial sees the upsell instead of action buttons that would 403. if (!hasSkvCapability) { return ( Skicka direkt till Skatteverket (valfritt)

Deklarationen är redan klar att lämnas in manuellt hos Skatteverket (se ovan). Med ett abonnemang kan du ansluta med BankID och skicka den direkt härifrån, samt validera, spara utkast och signera.

Direktinlämning till Skatteverket kräver ett abonnemang.
) } // Not connected. The momsdeklaration is already complete and can be filed // manually at skatteverket.se with no connection (see the manual-filing card // rendered above this panel). Connecting is an optional convenience for // submitting directly from Accounted, so frame it that way: users must not // read the connection as a prerequisite for producing or filing the report. if (!status?.connected) { return ( Skicka direkt till Skatteverket (valfritt) {error && (
{error}
)}

Deklarationen är redan klar att lämnas in manuellt hos Skatteverket (se ovan). Vill du slippa skriva in siffrorna själv kan du ansluta med BankID och skicka den direkt härifrån, samt validera, spara utkast och signera.

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