From 432a8b60dcbd83ef635814022f206a0b34a5120d Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Tue, 5 May 2026 17:08:29 +0200 Subject: [PATCH] feat(skatteverket): rewrite AGI flow against real Skatteverket RAML (#391) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(skatteverket): rewrite AGI flow against real Skatteverket RAML The previous AGI client posted JSON to URL paths that don't exist on Skatteverket's gateway and used invented field names. POST /underlag actually accepts application/xml, and the lock/kvittenser operations live on the separate hanteraredovisningsperiod API. Verified against dev_docs/arbetsgivardeklaration-inlamning(1.7.7) and arbetsgivardeklaration-hantera-redovisningsperiod(1.2.8) RAMLs. - Replace fictional types with real schemas (kontrollresultat, granskningsunderlag, kvittenser, error envelope) - Rewrite agi-client into 9 functions matching the documented flow: /underlag (XML) -> kontrollresultat -> spara -> skapaGranskningsunderlag -> kvittenser, plus las/lasUpp on the hantera API - Drop agi-mappers entirely; lib/salary/agi/xml-generator.ts already produces schema-valid XML, so the extension just feeds agi_declarations.xml_content to POST /underlag - Extend skvRequest with a contentType option so AGI can post XML - AGIPanel state machine: underlag_submitted -> awaiting_signing -> signed, with kontrollresultat polling and normalized findings - Add the agd OAuth scope (confirmed from SKV's Tjanstebeskrivning Arbetsgivardeklaration inlamning v1.7, section 4.1.2.2) - Add Skatteverket connect step to NewUserChecklist alongside the existing SIE/old-system import and bank steps; track hasSkatteverketConnected in OnboardingProgress - Update orchestrator route + tests to point at the new /agi/submit endpoint - Declare new optional base-URL env vars in the manifest Co-Authored-By: Claude Opus 4.7 (1M context) * fix(skatteverket): address PR review findings on AGI flow - Surface INCORRECT_DATA felrapport link in AGIPanel skapaGranskningsunderlag returns 409 with a felrapport URL when SKV rejects the underlag. The link was persisted as `signeringslank` with status `underlag_rejected`, but the render condition only fired for `awaiting_signing`, leaving the link unreachable. Add a distinct destructive-styled block so the user can open the felrapport in Mina Sidor. - /agi/underlag DELETE clears local submission state Add optional `period` query param. When supplied, clear `agi_submission_{period}` directly. When not, fall back to scanning recent agi_submission_* keys for the matching inlamningId. Without this, an aborted underlag left a stale `underlag_submitted` entry in extension_data and the UI couldn't progress. - Re-add salary-run status guard inside loadAGIXml The orchestrator at app/api/salary/runs/[id]/agi/submit/route.ts has this check, but the extension endpoint is also reachable directly from AGIPanel and must enforce it itself. Per BFL 5 kap and SFL 26 kap, AGI must reflect finalised payroll data; submitting from a draft/cancelled run would emit incorrect figures. - Move agi_declarations.status='exported' from /agi/submit to /agi/spara Setting status on underlag-ingest was wrong because a DONE_REJECTED kontrollresultat would leave the row falsely marked as exported. The transition now happens only after the spara call commits the underlag to Eget utrymme. /agi/spara accepts salaryRunId in the body for the fast path and falls back to scanning agi_submission_* state otherwise. - Move salary_runs.agi_submitted_at stamp to kvittenser observation The orchestrator was stamping at underlag-ingest, but no later code updated the column on signing. Removed the orchestrator stamp; the /agi/kvittenser handler now stamps salary_runs.agi_submitted_at to kvittens.signeradTid (mirroring SKV's own timestamp) when it pins the receipt to the matching agi_declarations row. - Tighten misleading JSDoc in agi-client.ts taBortSparadInlamning is on the inlämning API, not hantera; the old layout grouped it under a "hantera API" heading and tripped an automated reviewer. Restructured into separate "period management" and "cleanup" blocks. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(skatteverket): address Swedish compliance review on AGI flow Follow-up to review on https://github.com/erp-mafia/gnubok/pull/391. - Migration adds 'pending_signature' to agi_declarations.status Reusing 'exported' for the spara→kvittens interval misstated the filing outcome — Eget utrymme is a staging area, not a filing — which conflicts with BFNAR 2013:2 kap 8 / BFL 5 kap 5§ behandlingshistorik faithfulness. /agi/spara now sets 'pending_signature'; /agi/kvittenser later promotes to 'submitted' when a uuidKvittens is observed. - AGIPanel auto-polls /agi/kvittenser at 30s, 2 min and 5 min after the signing link is created Previously the kvittens (and therefore salary_runs.agi_submitted_at) was only stamped if the user manually returned to the panel and clicked "Hämta kvittens". Without that follow-up the audit trail showed a NULL submitted-at for an AGI that had actually been filed. Background polls capture the kvittens for the common case where the user signs in Mina Sidor and never returns to gnubok. Cleanup on unmount via useRef + useEffect. - Distinct MISSING_SCOPE error code on 403 invalid_scope Existing tokens lack the new 'agd' scope and surface as a generic ACCESS_DENIED today. The compliance reviewer pointed out that operators may interpret this as a data error and submit a corrected AGI with altered figures. New SkatteverketAuthError code maps SKV's invalid_scope body to a clear "reconnect via Inställningar → Skatteverket" message; routes to 401 (token-level remediation). - Refine deadline copy in AGIPanel The standard AGI deadline is the 12th regardless of company size; the 17th only applies in January and August for employers with turnover ≤ 40 MSEK. Surface that nuance instead of saying just "12:e". Co-Authored-By: Claude Opus 4.7 (1M context) * fix(skatteverket): server-side kvittens reconciliation cron Round 2 of compliance review on https://github.com/erp-mafia/gnubok/pull/391. - /api/extensions/skatteverket/agi/kvittenser/cron Walks every agi_declarations row in 'pending_signature' status, fetches kvittenser via the matching token, and on a hit promotes the row to 'submitted' + stamps salary_runs.agi_submitted_at. Authoritative source for the audit trail per BFNAR 2013:2 kap 8 / BFL 5 kap 5§ — the AGIPanel client-side timers from the previous round remain as the fast-path UX, but no longer carry the audit-trail responsibility on their own. Per-row errors are skipped, not abort-the-run. 50s budget. Scheduled every 2 hours in vercel.json. - AGIStatus union now includes 'pending_signature' Without this update, downstream code reading the union would have rejected the new status as unknown. The migration extending the DB CHECK constraint shipped in the previous commit; this brings the type layer into sync. - Stale comment update in AGIPanel.tsx Referred to status='exported' from before the rename. Now reads 'pending_signature', matching the actual handler behavior. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(skatteverket): close audit-trail gaps from compliance round 3 - Cron now writes submitted_by from the token-owning auth.users row Previously left NULL with a "system actor" comment. The token row was created when the operator authenticated with BankID, and the kvittens' signeradAv refers to the same person — so writing the user_id from skatteverket_tokens captures actor traceability without inventing a system identity. Closes the BFL 5 kap 6§ / BFNAR 2013:2 kap 8 gap on cron-reconciled rows. - /agi/spara monotonicity guard Adds .in('status', ['generated', 'exported']) to the row update so a delayed /agi/spara call after the cron (or interactive /agi/kvittenser) has already promoted the row to 'submitted'/'accepted' won't silently regress it back to 'pending_signature'. behandlingshistorik must advance only. - DONE_REJECTED / DONE_FAILED → status='rejected' /agi/kontrollresultat handler now flips the matching agi_declarations row to 'rejected' on a terminal SKV failure, using the same cached-submission-state lookup pattern /agi/spara already uses. Without this the row sat at 'generated' indefinitely even though SKV considered the underlag failed. Same monotonicity guard prevents regressing a successfully-filed row. - Deadline criterion: lönesumma, not omsättning AGIPanel pendingText. SFL 26 kap's relaxed-deadline criterion (17:e in Jan/Aug) is the employer's total taxable wages, not turnover. Internal reference (.claude/skills/swedish-payroll/references/agi-filing.md) used the colloquial "turnover"; statutory wording is "lönesumma". Co-Authored-By: Claude Opus 4.7 (1M context) * fix(skatteverket): close round-4 audit-trail and UX gaps - agi_submitted_at: NULL when signeradTid absent Both /agi/kvittenser handler and the kvittens cron previously fell back to new Date().toISOString() if SKV's kvittens lacked signeradTid. Substituting wall-clock now() falsifies the filing moment in behandlingshistorik (BFNAR 2013:2 kap 8 / BFL 5 kap 6§). Now leaves the column NULL and logs a warning. Status flip to 'submitted' still happens — the audit gap was timing only. - Proactive missing-agd-scope banner SkatteverketConnectPanel and AGIPanel now warn when the stored token lacks the agd scope. Tokens issued before the agd rollout would otherwise 403 with invalid_scope at submission time, often too close to the AGI deadline. SkatteverketConnectPanel mirrors the existing "skattekonto saknas" pattern; AGIPanel surfaces a banner in the connected state and links to /settings/skatteverket. - Granskningsunderlag isError keys on tillstand only Previous check mixed HTTP 409 with the INCORRECT_DATA tillstand string. A future SKV addition like RECEIVING returned with HTTP 200 would have slipped through as awaiting_signing. Now keys solely on tillstand: only LOCKED_FOR_SIGNING / UNLOCKED are treated as signable; everything else (INCORRECT_DATA, RECEIVING, CALCULATING, SIGNING) routes to underlag_rejected. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(skatteverket): close round-5 audit-trail and recovery gaps - agi_submitted_at: stamp with reconciliation time when signeradTid absent Round 4 left the column NULL on missing signeradTid to avoid falsifying the signing moment. Round 5 pointed out that NULL hides that the filing *occurred* — also a behandlingshistorik integrity violation. Resolution: presence of uuidKvittens proves SKV signed and accepted the AGI, so we stamp with signeradTid || now() and warn-log when fallback is used. Both /agi/kvittenser handler and the kvittens cron. - Persist signeradAv + full kvittens in agi_declarations.response_data submitted_by is the auth.users UUID we have on hand (the polling / reconciling user). The legally load-bearing signer identity is kvittens.signeradAv (a personnummer) — which the token user_id does NOT necessarily match (e.g. bookkeeper vs deklarationsombud). The existing response_data jsonb column now holds the full kvittens record, preserving signeradAv for the audit trail (BFL 5 kap 6§ / BFNAR 2013:2 kap 8) without a schema change. Cron path also marks reconciledBy='cron'. - /agi/spara monotonicity: allow recovery from 'rejected' Previously .in('status', ['generated', 'exported']) excluded rejected rows, so a successful re-submission after a prior rejection couldn't promote the row to pending_signature — it silently stayed rejected. The xml-route reuses the same agi_declarations row when re-generating XML, so this is the realistic recovery path. Added 'rejected' to the allowed-from list. 'submitted'/'accepted' still blocked (no regression from filed states). - Fix misleading agi-client.ts comment Claimed users could "fix the errors in Mina Sidor" after a DONE_REJECTED save. Mina Sidor doesn't expose in-place editing; the correct recovery is to regenerate XML and resubmit. Updated the agiSparaUnderlag JSDoc to describe the actual flow. - Deadline copy: "vars sammanlagda lönesumma understiger 40 MSEK" Reads more cleanly than "≤ 40 MSEK" and matches the phrasing the compliance reviewer suggested. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(skatteverket): tighten /agi/spara guard and clarify deadline copy (round 6) - Drop 'exported' from /agi/spara allowed-from states Audit confirmed no code path writes status='exported' today; the value is preserved in the schema (and union) for the legacy manual-download path that no longer has a writer. Allowing the spara handler to flip an 'exported' row to 'pending_signature' would conflate two distinct filing attempts on a single row, weakening the chain of custody (BFL 5 kap 6§). Tightened to .in(['generated', 'rejected']) — same recovery path for re-submission after rejection, no path for the dormant state. - Deadline copy: explicit "per år" qualifier The 40 MSEK threshold is annual lönesumma, not per-payment. Adding "per år" closes the (admittedly thin) misread the compliance reviewer flagged. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- app/(dashboard)/page.tsx | 6 + .../skatteverket/agi/kvittenser/cron/route.ts | 232 ++++++ .../[id]/agi/submit/__tests__/route.test.ts | 8 +- app/api/salary/runs/[id]/agi/submit/route.ts | 58 +- components/dashboard/DashboardContent.tsx | 1 + components/onboarding/NewUserChecklist.tsx | 82 +- components/salary/AGIPanel.tsx | 427 +++++++--- .../settings/SkatteverketConnectPanel.tsx | 10 +- .../__tests__/agi-mappers.test.ts | 185 ----- extensions/general/skatteverket/index.ts | 781 +++++++++++------- .../general/skatteverket/lib/agi-client.ts | 390 ++++++--- .../general/skatteverket/lib/agi-mappers.ts | 93 --- .../general/skatteverket/lib/api-client.ts | 32 +- extensions/general/skatteverket/lib/oauth.ts | 7 +- extensions/general/skatteverket/manifest.json | 5 +- extensions/general/skatteverket/types.ts | 171 ++-- ...000_agi_declarations_pending_signature.sql | 37 + types/index.ts | 10 +- vercel.json | 4 + 19 files changed, 1645 insertions(+), 894 deletions(-) create mode 100644 app/api/extensions/skatteverket/agi/kvittenser/cron/route.ts delete mode 100644 extensions/general/skatteverket/__tests__/agi-mappers.test.ts delete mode 100644 extensions/general/skatteverket/lib/agi-mappers.ts create mode 100644 supabase/migrations/20260505100000_agi_declarations_pending_signature.sql 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')) && (