diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx index cd96fe83..55091bbd 100644 --- a/app/(dashboard)/page.tsx +++ b/app/(dashboard)/page.tsx @@ -75,6 +75,7 @@ export default async function DashboardPage() { { count: sieImportCount }, { count: staleUncategorizedCount }, { count: uncategorizedCount }, + { count: skatteverketTokenCount }, ] = await Promise.all([ supabase.from('profiles').select('full_name').eq('id', user.id).single(), supabase.from('company_settings').select('*').eq('company_id', companyId).single(), @@ -101,6 +102,10 @@ export default async function DashboardPage() { supabase.from('sie_imports').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'completed'), supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId).is('journal_entry_id', null).not('is_business', 'eq', false).lt('date', new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]), supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId).is('is_business', null), + // Skatteverket tokens are user-scoped (one BankID identity per user) but + // carry the active company_id; either filter would work — we use user_id + // because that's what the token-store reads/writes against. + supabase.from('skatteverket_tokens').select('*', { count: 'exact', head: true }).eq('user_id', user.id), ]) const firstName = profile?.full_name?.split(' ')[0] || null @@ -115,6 +120,7 @@ export default async function DashboardPage() { hasInvoices: (invoiceCount || 0) > 0, hasBankConnected: (transactionCount || 0) > 0, hasSIEImport: (sieImportCount || 0) > 0, + hasSkatteverketConnected: (skatteverketTokenCount || 0) > 0, } // Calculate totals from journal entry lines using account classes diff --git a/app/api/extensions/skatteverket/agi/kvittenser/cron/route.ts b/app/api/extensions/skatteverket/agi/kvittenser/cron/route.ts new file mode 100644 index 00000000..d1d8e6e6 --- /dev/null +++ b/app/api/extensions/skatteverket/agi/kvittenser/cron/route.ts @@ -0,0 +1,232 @@ +import { createClient } from '@supabase/supabase-js' +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { verifyCronSecret } from '@/lib/auth/cron' +import { agiGetKvittenser } from '@/extensions/general/skatteverket/lib/agi-client' +import { SkatteverketAuthError } from '@/extensions/general/skatteverket/lib/api-client' +import { formatRedovisare, formatRedovisningsperiod } from '@/lib/skatteverket/format' + +ensureInitialized() + +export const maxDuration = 60 + +/** + * GET /api/extensions/skatteverket/agi/kvittenser/cron + * + * Daily kvittens reconciliation. The user-side flow signs the AGI in + * Skatteverket's Mina Sidor; the resulting kvittens (uuidKvittens + + * signeradTid) is the canonical filing receipt. Without this cron, + * `salary_runs.agi_submitted_at` only gets stamped when the user returns + * to the panel and clicks "Hämta kvittens" or stays on the page long + * enough for the in-browser timers to fire — which is unreliable, and + * leaves the audit trail out of step with reality (BFNAR 2013:2 kap 8 + + * BFL 5 kap 5§ require the behandlingshistorik to faithfully record + * filing events). + * + * Strategy: walk every `agi_declarations` row in `pending_signature` + * status, look up its arbetsgivare/period, fetch /kvittenser via the + * extension's per-user token, and on a hit promote the row to + * `submitted` + stamp salary_runs.agi_submitted_at. + * + * Per-row errors are logged and skipped — one expired token shouldn't + * block other companies' reconciliation. + * + * Time budget: 50s (Vercel default 60s function timeout with 10s margin). + */ +export async function GET(request: Request) { + const authError = verifyCronSecret(request) + if (authError) return authError + + if (process.env.SKATTEVERKET_ENABLED !== 'true') { + return NextResponse.json({ message: 'Skatteverket extension disabled', processed: 0 }) + } + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL + const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY + if (!supabaseUrl || !supabaseServiceKey) { + return NextResponse.json({ error: 'Missing Supabase configuration' }, { status: 500 }) + } + + const supabase = createClient(supabaseUrl, supabaseServiceKey) + + const { data: pending, error: pendingError } = await supabase + .from('agi_declarations') + .select('id, company_id, salary_run_id, period_year, period_month') + .eq('status', 'pending_signature') + .order('created_at', { ascending: true }) + .limit(100) + + if (pendingError) { + console.error('[agi-kvittenser-cron] Failed to fetch pending declarations', { + message: pendingError.message, + code: pendingError.code, + }) + return NextResponse.json({ error: 'Failed to fetch pending declarations' }, { status: 500 }) + } + + if (!pending || pending.length === 0) { + return NextResponse.json({ message: 'No pending signatures', processed: 0 }) + } + + const startTime = Date.now() + const TIME_BUDGET_MS = 50_000 + + type Result = { + declarationId: string + companyId: string + period: string + status: 'signed' | 'still_pending' | 'no_token' | 'no_company_settings' | 'expired_token' | 'error' + error?: string + } + const results: Result[] = [] + + for (const decl of pending) { + if (Date.now() - startTime > TIME_BUDGET_MS) { + console.log(`[agi-kvittenser-cron] Time budget reached after ${results.length} declarations`) + break + } + + const companyId = decl.company_id as string + const declarationId = decl.id as string + const period = formatRedovisningsperiod('monthly', decl.period_year as number, decl.period_month as number) + + try { + // The token table is user-scoped (one BankID identity per user) but + // also carries company_id. Match on company_id so a multi-company + // operator's token is reused only for the company that owns the AGI. + const { data: token } = await supabase + .from('skatteverket_tokens') + .select('user_id') + .eq('company_id', companyId) + .maybeSingle() + + if (!token?.user_id) { + results.push({ declarationId, companyId, period, status: 'no_token' }) + continue + } + + const { data: settings } = await supabase + .from('company_settings') + .select('org_number, entity_type') + .eq('company_id', companyId) + .single() + + if (!settings?.org_number) { + results.push({ declarationId, companyId, period, status: 'no_company_settings' }) + continue + } + + const arbetsgivare = formatRedovisare( + settings.org_number as string, + settings.entity_type as 'enskild_firma' | 'aktiebolag', + ) + + const kvittRes = await agiGetKvittenser(supabase, token.user_id as string, arbetsgivare, period) + if (!kvittRes.ok) { + results.push({ + declarationId, companyId, period, + status: 'error', + error: kvittRes.error, + }) + continue + } + + const kvittens = kvittRes.data.kvittenser?.[0] + if (!kvittens?.uuidKvittens) { + results.push({ declarationId, companyId, period, status: 'still_pending' }) + continue + } + + // The presence of uuidKvittens confirms SKV signed and accepted + // the AGI. signeradTid is the precise signing moment; if SKV omits + // it we fall back to reconciliation time + warn so the discrepancy + // is investigable. Leaving NULL would hide that the filing occurred + // at all, which itself misstates behandlingshistorik (BFNAR 2013:2 + // kap 8 / BFL 5 kap 6§). The fallback only applies on this code + // path because we're inside the kvittens-found branch above. + const submittedAt = kvittens.signeradTid || new Date().toISOString() + if (!kvittens.signeradTid) { + console.warn('[agi-kvittenser-cron] kvittens missing signeradTid; using reconciliation time', { + declarationId, companyId, period, uuidKvittens: kvittens.uuidKvittens, + }) + } + + // submitted_by is the token-owning auth.users row — the human who + // connected via BankID. The legally load-bearing signer identity + // is kvittens.signeradAv (a personnummer), which the token user_id + // does NOT necessarily match (e.g. if the connected user is a + // bookkeeper but the deklarationsombud signed). We preserve the + // full kvittens in response_data so the audit trail (BFL 5 kap 6§, + // BFNAR 2013:2 kap 8) records the actual BankID signer regardless + // of who triggered the reconciliation. + await supabase + .from('agi_declarations') + .update({ + status: 'submitted', + kvittensnummer: kvittens.uuidKvittens, + submitted_at: submittedAt, + submitted_by: token.user_id, + response_data: { + signeradAv: kvittens.signeradAv ?? null, + signeradTid: kvittens.signeradTid ?? null, + uuidKvittens: kvittens.uuidKvittens, + arbetsgivare: kvittens.arbetsgivare ?? null, + period: kvittens.period ?? null, + underlag: kvittens.underlag ?? null, + reconciledBy: 'cron', + }, + }) + .eq('id', declarationId) + + if (decl.salary_run_id) { + await supabase + .from('salary_runs') + .update({ agi_submitted_at: submittedAt }) + .eq('id', decl.salary_run_id) + .eq('company_id', companyId) + } + + // Clear the locally-cached submission state so the panel doesn't + // pop a stale "awaiting signature" view if the user revisits. + await supabase + .from('extension_data') + .delete() + .eq('company_id', companyId) + .eq('extension_id', 'skatteverket') + .eq('key', `agi_submission_${period}`) + + results.push({ declarationId, companyId, period, status: 'signed' }) + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' + + if ( + err instanceof SkatteverketAuthError && + (err.code === 'REFRESH_EXHAUSTED' || err.code === 'SESSION_EXPIRED' || err.code === 'TOKEN_CORRUPTED' || err.code === 'MISSING_SCOPE') + ) { + results.push({ declarationId, companyId, period, status: 'expired_token', error: err.code }) + continue + } + + console.error('[agi-kvittenser-cron] Reconciliation failed', { declarationId, companyId, period, message }) + results.push({ declarationId, companyId, period, status: 'error', error: message }) + } + } + + const signed = results.filter(r => r.status === 'signed').length + const stillPending = results.filter(r => r.status === 'still_pending').length + const expired = results.filter(r => r.status === 'expired_token').length + const errors = results.filter(r => r.status === 'error').length + + console.log( + `[agi-kvittenser-cron] Processed ${results.length}: ${signed} signed, ${stillPending} still pending, ${expired} expired, ${errors} errors`, + ) + + return NextResponse.json({ + processed: results.length, + signed, + stillPending, + expired, + errors, + results, + }) +} diff --git a/app/api/salary/runs/[id]/agi/submit/__tests__/route.test.ts b/app/api/salary/runs/[id]/agi/submit/__tests__/route.test.ts index 4673da3a..4f34abc5 100644 --- a/app/api/salary/runs/[id]/agi/submit/__tests__/route.test.ts +++ b/app/api/salary/runs/[id]/agi/submit/__tests__/route.test.ts @@ -186,11 +186,13 @@ describe('POST /api/salary/runs/[id]/agi/submit', () => { expect(body.data.salaryRunId).toBe('run-1') expect(body.data.periodYear).toBe(2026) expect(body.data.periodMonth).toBe(3) - expect(body.data.message).toContain('utkast') + expect(body.data.message).toContain('underlag') - // Verify the extension endpoint was called correctly + // Verify the extension endpoint was called correctly. The orchestrator + // forwards to /agi/submit (XML POST /underlag flow), not the old + // /agi/draft endpoint that mapped to a non-existent SKV URL. expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining('/api/extensions/ext/skatteverket/agi/draft'), + expect.stringContaining('/api/extensions/ext/skatteverket/agi/submit'), expect.objectContaining({ method: 'POST', body: JSON.stringify({ salaryRunId: 'run-1' }), diff --git a/app/api/salary/runs/[id]/agi/submit/route.ts b/app/api/salary/runs/[id]/agi/submit/route.ts index 79eb301a..fc4af82d 100644 --- a/app/api/salary/runs/[id]/agi/submit/route.ts +++ b/app/api/salary/runs/[id]/agi/submit/route.ts @@ -16,8 +16,8 @@ ensureInitialized() * 3. Calls the Skatteverket extension to save draft + lock for signing * 4. Returns the signeringslänk for BankID signing * - * The user then signs on Skatteverket's site. The frontend polls - * GET /api/extensions/ext/skatteverket/agi/submitted to detect completion. + * The user then signs on Skatteverket's site (Mina Sidor). The frontend + * polls /api/extensions/ext/skatteverket/agi/kvittenser to detect completion. */ export async function POST( request: Request, @@ -74,22 +74,26 @@ export async function POST( ) } - // The actual submission is done via the Skatteverket extension routes. - // This route provides the salary_run_id for the extension to load data from. - // The frontend should call: - // 1. POST /api/extensions/ext/skatteverket/agi/draft { salaryRunId } - // 2. PUT /api/extensions/ext/skatteverket/agi/lock ?arbetsgivare=...&period=... - // 3. User signs with BankID via signeringslänk - // 4. GET /api/extensions/ext/skatteverket/agi/submitted ?arbetsgivare=...&period=... + // The actual SKV interaction lives in the Skatteverket extension. This + // route is a thin orchestrator: it forwards the salary_run_id to the + // extension's /agi/submit endpoint (which posts the stored XML underlag), + // then records that the AGI submission process has started. // - // This endpoint kicks off step 1 and returns the info needed for step 2+. + // The frontend (AGIPanel) handles the rest of the flow: + // 1. POST /api/extensions/ext/skatteverket/agi/submit { salaryRunId } + // → returns { inlamningId } + // 2. GET /api/extensions/ext/skatteverket/agi/kontrollresultat?inlamningId=... + // → poll until status != PROCESSING + // 3. POST /api/extensions/ext/skatteverket/agi/spara { inlamningId } + // 4. POST /api/extensions/ext/skatteverket/agi/granskningsunderlag?arbetsgivare&period + // → returns { link } (Mina Sidor BankID signing) + // 5. GET /api/extensions/ext/skatteverket/agi/kvittenser?arbetsgivare&period const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' try { - // Call the extension's draft endpoint internally - const draftResponse = await fetch( - `${appUrl}/api/extensions/ext/skatteverket/agi/draft`, + const submitResponse = await fetch( + `${appUrl}/api/extensions/ext/skatteverket/agi/submit`, { method: 'POST', headers: { @@ -100,21 +104,25 @@ export async function POST( } ) - if (!draftResponse.ok) { - const errorData = await draftResponse.json().catch(() => ({ error: 'Okänt fel' })) + if (!submitResponse.ok) { + const errorData = await submitResponse.json().catch(() => ({ error: 'Okänt fel' })) return NextResponse.json( - { error: errorData.error || `Kunde inte spara AGI-utkast (${draftResponse.status})` }, - { status: draftResponse.status } + { error: errorData.error || `Kunde inte skicka AGI-underlag (${submitResponse.status})` }, + { status: submitResponse.status } ) } - const draftData = await draftResponse.json() + const submitData = await submitResponse.json() - // Update submission timestamp on salary run - await supabase - .from('salary_runs') - .update({ agi_submitted_at: new Date().toISOString() }) - .eq('id', id) + // Don't stamp salary_runs.agi_submitted_at here. The underlag has only + // been ingested; the user still has to pass kontrollresultat, save, + // produce a granskningsunderlag, and sign with BankID before the AGI is + // actually filed. Recording the submission time at ingest would make the + // audit trail lie about when filing completed. + // + // The real timestamp is set by the kvittenser handler in the extension + // (extensions/general/skatteverket/index.ts /agi/kvittenser route) when + // it observes a uuidKvittens for the period, mirroring SKV's signeradTid. await eventBus.emit({ type: 'agi.submitted', @@ -129,11 +137,11 @@ export async function POST( return NextResponse.json({ data: { - ...draftData.data, + ...submitData.data, salaryRunId: id, periodYear: run.period_year, periodMonth: run.period_month, - message: 'AGI sparad som utkast hos Skatteverket. Lås och signera med BankID för att slutföra.', + message: 'AGI-underlag inläst hos Skatteverket. Skapa granskningsunderlag och signera med BankID i Mina Sidor.', }, }) } catch (err) { diff --git a/components/dashboard/DashboardContent.tsx b/components/dashboard/DashboardContent.tsx index 4b7b0227..6b33251e 100644 --- a/components/dashboard/DashboardContent.tsx +++ b/components/dashboard/DashboardContent.tsx @@ -83,6 +83,7 @@ export default function DashboardContent({ firstName, companyId, settings, summa if (setupGateActive) { return ( { localStorage.setItem(setupFreshStartKey(companyId), 'true') setSetupGateActive(false) diff --git a/components/onboarding/NewUserChecklist.tsx b/components/onboarding/NewUserChecklist.tsx index da789a91..c26ea2c8 100644 --- a/components/onboarding/NewUserChecklist.tsx +++ b/components/onboarding/NewUserChecklist.tsx @@ -3,6 +3,8 @@ import Link from 'next/link' import { ArrowRight, + CheckCircle2, + FileCheck, FileText, Landmark, ArrowRightLeft, @@ -17,14 +19,21 @@ const branding = getBranding() interface NewUserChecklistProps { onFreshStart: () => void className?: string + /** + * Whether the active user already has a Skatteverket OAuth connection. + * When true the Skatteverket step renders as completed instead of as a CTA. + */ + hasSkatteverketConnected?: boolean } export default function NewUserChecklist({ onFreshStart, className, + hasSkatteverketConnected, }: NewUserChecklistProps) { const hasMigration = ENABLED_EXTENSION_IDS.has('arcim-migration') const hasBanking = ENABLED_EXTENSION_IDS.has('enable-banking') + const hasSkatteverket = ENABLED_EXTENSION_IDS.has('skatteverket') return (
@@ -111,7 +120,7 @@ export default function NewUserChecklist({
{/* Step 2: Connect bank */} -
+
2 @@ -146,6 +155,77 @@ export default function NewUserChecklist({
+ {/* Step 3: Connect Skatteverket — only when the extension is enabled. + Optional: connecting here lets gnubok submit moms + AGI and read + skattekonto saldo, but the user can skip and do it later from + /settings/skatteverket. The OAuth flow returns to the dashboard + via return_to=/, which clears the gate via the same path the + user would take naturally. */} + {hasSkatteverket && ( +
+
+ + {hasSkatteverketConnected + ? + : '3'} + +

+ Anslut Skatteverket +

+ — valfritt +
+ +
+ {hasSkatteverketConnected ? ( +
+
+
+ +
+
+

+ Skatteverket anslutet +

+

+ Du kan nu skicka momsdeklaration och AGI direkt, samt se saldot på skattekontot. +

+
+
+
+ ) : ( + // eslint-disable-next-line @next/next/no-html-link-for-pages -- /api route, not a Next page + would route via Next's client + // router which doesn't follow cross-origin redirects. + href="/api/extensions/ext/skatteverket/authorize?return_to=/" + className="group block p-4 sm:p-5 rounded-xl border border-border/60 hover:border-primary/40 hover:bg-primary/[0.02] transition-all duration-150 active:scale-[0.99]" + > +
+
+ +
+
+

+ Anslut till Skatteverket med BankID +

+

+ Skicka momsdeklaration och arbetsgivardeklaration direkt, och hämta saldot på skattekontot — utan att lämna {branding.appName.toLowerCase()}. +

+
+ +
+
+ )} +
+
+ )} + {/* Escape hatch */}
diff --git a/components/salary/AGIPanel.tsx b/components/salary/AGIPanel.tsx index 0b06cfe9..c0caf9dc 100644 --- a/components/salary/AGIPanel.tsx +++ b/components/salary/AGIPanel.tsx @@ -1,12 +1,11 @@ 'use client' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { AlertCircle, CheckCircle2, Download, ExternalLink, - FileCheck, Link2, Link2Off, Loader2, @@ -41,18 +40,60 @@ interface ConnectionStatus { expiresAt?: string } -interface KontrollResult { - kod: string - status: 'ERROR' | 'WARNING' - beskrivning: 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?: 'draft_saved' | 'draft_locked' | 'signed' + 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 - tidpunkt?: string - inlamningId?: 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' @@ -71,7 +112,7 @@ export function AGIPanel(props: AGIPanelProps) { const [extensionDisabled, setExtensionDisabled] = useState(false) const [status, setStatus] = useState(null) const [submission, setSubmission] = useState(null) - const [kontroller, setKontroller] = useState([]) + const [kontroller, setKontroller] = useState([]) const [loading, setLoading] = useState(true) const [actionLoading, setActionLoading] = useState(null) const [error, setError] = useState(null) @@ -117,81 +158,215 @@ export function AGIPanel(props: AGIPanelProps) { fetchSubmission() }, [fetchStatus, fetchSubmission]) + // Background kvittens-polling timers (see scheduleKvittensPolls below). + // Held in a ref so the unmount-cleanup effect can cancel them if the + // user leaves the page mid-signing. + const kvittensTimers = useRef[]>([]) + useEffect(() => { + return () => { + for (const t of kvittensTimers.current) clearTimeout(t) + kvittensTimers.current = [] + } + }, []) + + /** + * 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 handleConnect = () => { window.location.href = '/api/extensions/ext/skatteverket/authorize' } - const handleValidate = async () => { - setActionLoading('validate') + /** + * 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 we automatically call /agi/spara to commit into Eget + * utrymme, mirroring the user's intent ("send AGI") and matching what the + * old draft-then-lock UX promised. + * + * 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 res = await fetch('/api/extensions/ext/skatteverket/agi/validate', { + const submitRes = await fetch('/api/extensions/ext/skatteverket/agi/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ salaryRunId }), }) - const json = await res.json() - if (!res.ok || json.error) { - setError(json.error || `Validering misslyckades (${res.status})`) + 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 } - const controls: KontrollResult[] = json.data?.kontrollresultat?.resultat ?? [] - setKontroller(controls) - const errs = controls.filter(c => c.status === 'ERROR') - if (errs.length === 0) setSuccess('Valideringen godkänd') - else setError(`${errs.length} valideringsfel hittades`) - } catch (e) { - setError(e instanceof Error ? e.message : 'Kunde inte validera AGI') - } finally { - setActionLoading(null) - } - } - const handleSaveDraft = async () => { - setActionLoading('draft') - setError(null) - setSuccess(null) - try { - const res = await fetch('/api/extensions/ext/skatteverket/agi/draft', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ salaryRunId }), - }) - const json = await res.json() - if (!res.ok || json.error) { - setError(json.error || `Kunde inte spara utkast (${res.status})`) + // 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 } - setSuccess('AGI-utkast sparat hos Skatteverket') + + const findings = extractFindings(kr) + setKontroller(findings) + + if (kr.status === 'DONE_SUCCESS') { + const sparaRes = await fetch('/api/extensions/ext/skatteverket/agi/spara', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + // Include salaryRunId so the handler can promote the matching + // agi_declarations row to status='pending_signature' without + // doing a fallback lookup against locally-cached submission state. + body: JSON.stringify({ inlamningId, salaryRunId }), + }) + const sparaJson = await sparaRes.json() + if (!sparaRes.ok || sparaJson.error) { + setError(sparaJson.error || `Kunde inte spara underlag (${sparaRes.status})`) + return + } + setSuccess('Underlag accepterat och sparat hos Skatteverket. Skapa granskningsunderlag för att fortsätta till BankID-signering.') + } 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 spara utkast') + setError(e instanceof Error ? e.message : 'Kunde inte skicka AGI') } finally { setActionLoading(null) } } - const handleLock = async () => { - setActionLoading('lock') + /** + * 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/lock?arbetsgivare=${encodeURIComponent(arbetsgivare)}&period=${period}`, - { method: 'PUT' }, + `/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 låsa AGI (${res.status})`) + setError(json.error || `Kunde inte skapa granskningsunderlag (${res.status})`) return } - setSuccess('AGI låst — öppna signeringslänken för att signera med BankID.') + 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 låsa AGI') + setError(e instanceof Error ? e.message : 'Kunde inte skapa granskningsunderlag') } finally { setActionLoading(null) } @@ -203,8 +378,8 @@ export function AGIPanel(props: AGIPanelProps) { setSuccess(null) try { const res = await fetch( - `/api/extensions/ext/skatteverket/agi/lock?arbetsgivare=${encodeURIComponent(arbetsgivare)}&period=${period}`, - { method: 'DELETE' }, + `/api/extensions/ext/skatteverket/agi/lasUpp?arbetsgivare=${encodeURIComponent(arbetsgivare)}&period=${period}`, + { method: 'POST' }, ) const json = await res.json() if (!res.ok || json.error) { @@ -220,23 +395,30 @@ export function AGIPanel(props: AGIPanelProps) { } } + /** + * 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/submitted?arbetsgivare=${encodeURIComponent(arbetsgivare)}&period=${period}`, + `/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 inlämningsstatus') + setError(json.error || 'Kunde inte hämta kvittenser') return } - if (json.data?.kvittensnummer) { - setSuccess('AGI har lämnats in') + const kvittens = json.data?.kvittenser?.[0] + if (kvittens?.uuidKvittens) { + setSuccess('AGI har signerats och lämnats in.') } else { - setSuccess('Ingen inlämning hittades än för perioden') + setSuccess('Ingen signerad kvittens hittades än för perioden.') } await fetchSubmission() onChange?.() @@ -304,8 +486,16 @@ export function AGIPanel(props: AGIPanelProps) { } const subState = submission?.status - const isLocked = subState === 'draft_locked' + 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 ( @@ -319,6 +509,29 @@ export function AGIPanel(props: AGIPanelProps) { + {/* 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 && ( +
+

+ Anslutningen mot Skatteverket saknar behörighet för Arbetsgivardeklaration +

+

+ Din anslutning utfärdades innan AGI-stödet aktiverades. Koppla + bort och anslut igen via Inställningar → Skatteverket för att + kunna skicka AGI direkt. +

+ + Öppna inställningar + +
+ )} + {/* Status summary */}
- {submission?.signeringslank && isLocked && ( + {/* 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 && (

Utkastet är låst och redo att signeras

@@ -362,18 +580,44 @@ export function AGIPanel(props: AGIPanelProps) {

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

+ Felaktiga underlag — granskningsunderlag kunde inte signeras +

+

+ {submission.meddelande || 'Skatteverket avvisade underlaget. Öppna felrapporten för detaljer.'} +

+ + Öppna felrapport hos Skatteverket + +
+ )} + {kontroller.length > 0 && (
{kontroller.map((k, i) => (
- {k.kod} — {k.beskrivning} + {k.kod && {k.kod} } + {k.uppgiftsTyp && [{k.uppgiftsTyp}{k.specifikationsnummer ? ` #${k.specifikationsnummer}` : ''}] } + {k.beskrivning}
))} @@ -398,43 +642,30 @@ export function AGIPanel(props: AGIPanelProps) { - - {!isLocked ? ( - - ) : ( + + {awaitingSigning && ( )}
)} diff --git a/components/settings/SkatteverketConnectPanel.tsx b/components/settings/SkatteverketConnectPanel.tsx index d4091475..fbee1a49 100644 --- a/components/settings/SkatteverketConnectPanel.tsx +++ b/components/settings/SkatteverketConnectPanel.tsx @@ -23,6 +23,7 @@ const SCOPE_LABELS: Record = { ska: 'Skatteinformation', skahmst: 'Hemortskommun', skattekonto: 'Skattekonto', + agd: 'Arbetsgivardeklaration', } export function SkatteverketConnectPanel() { @@ -168,10 +169,17 @@ export function SkatteverketConnectPanel() { för att aktivera saldo- och transaktionsvyn.

)} + {!scopes.includes('agd') && ( +

+ Behörigheten för Arbetsgivardeklaration (AGI) saknas — koppla + från och anslut igen för att kunna skicka AGI direkt från {`gnubok`}. + Tokens utfärdade innan AGI-stödet aktiverades saknar denna scope. +

+ )}
- {(status.expired || !status.canRefresh || !scopes.includes('skattekonto')) && ( + {(status.expired || !status.canRefresh || !scopes.includes('skattekonto') || !scopes.includes('agd')) && (