feat(skatteverket): rewrite AGI flow against real Skatteverket RAML (#391)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-05 17:08:29 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent c03582b5c7
commit 432a8b60dc
19 changed files with 1645 additions and 894 deletions
+6
View File
@@ -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
@@ -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,
})
}
@@ -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' }),
+33 -25
View File
@@ -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) {
@@ -83,6 +83,7 @@ export default function DashboardContent({ firstName, companyId, settings, summa
if (setupGateActive) {
return (
<NewUserChecklist
hasSkatteverketConnected={!!onboardingProgress?.hasSkatteverketConnected}
onFreshStart={() => {
localStorage.setItem(setupFreshStartKey(companyId), 'true')
setSetupGateActive(false)
+81 -1
View File
@@ -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 (
<div className={cn('min-h-[75vh] flex flex-col items-center justify-center px-4 sm:px-0 stagger-enter', className)}>
@@ -111,7 +120,7 @@ export default function NewUserChecklist({
</div>
{/* Step 2: Connect bank */}
<div className="mb-8 md:mb-12">
<div className="mb-6 md:mb-8">
<div className="flex items-center gap-3 mb-4">
<span className="h-7 w-7 rounded-full bg-foreground text-background flex items-center justify-center text-xs font-semibold flex-shrink-0 tabular-nums">
2
@@ -146,6 +155,77 @@ export default function NewUserChecklist({
</div>
</div>
{/* 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 && (
<div className="mb-8 md:mb-12">
<div className="flex items-center gap-3 mb-4">
<span className={cn(
'h-7 w-7 rounded-full flex items-center justify-center text-xs font-semibold flex-shrink-0 tabular-nums',
hasSkatteverketConnected
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300'
: 'bg-foreground text-background',
)}>
{hasSkatteverketConnected
? <CheckCircle2 className="h-4 w-4" />
: '3'}
</span>
<h2 className="font-display text-base font-medium tracking-tight">
Anslut Skatteverket
</h2>
<span className="text-xs text-muted-foreground">— valfritt</span>
</div>
<div className="ml-0 sm:ml-10">
{hasSkatteverketConnected ? (
<div className="block p-4 sm:p-5 rounded-xl border border-emerald-500/30 bg-emerald-500/[0.04]">
<div className="flex items-start gap-3 sm:gap-4">
<div className="p-2 sm:p-2.5 rounded-lg bg-emerald-500/[0.10] flex-shrink-0">
<FileCheck className="h-4 w-4 sm:h-5 sm:w-5 text-emerald-700 dark:text-emerald-400" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm sm:text-base text-emerald-900 dark:text-emerald-200">
Skatteverket anslutet
</p>
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-1.5 leading-relaxed">
Du kan nu skicka momsdeklaration och AGI direkt, samt se saldot på skattekontot.
</p>
</div>
</div>
</div>
) : (
// eslint-disable-next-line @next/next/no-html-link-for-pages -- /api route, not a Next page
<a
// Plain anchor — the authorize endpoint 302-redirects to
// skatteverket.se; <Link> 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]"
>
<div className="flex items-start gap-3 sm:gap-4">
<div className="p-2 sm:p-2.5 rounded-lg bg-muted/60 group-hover:bg-primary/[0.08] transition-colors flex-shrink-0">
<FileCheck className="h-4 w-4 sm:h-5 sm:w-5 text-muted-foreground group-hover:text-primary transition-colors" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium group-hover:text-primary transition-colors text-sm sm:text-base">
Anslut till Skatteverket med BankID
</p>
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-1.5 leading-relaxed">
Skicka momsdeklaration och arbetsgivardeklaration direkt, och hämta saldot på skattekontot — utan att lämna {branding.appName.toLowerCase()}.
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-primary/60 mt-1 flex-shrink-0 transition-colors" />
</div>
</a>
)}
</div>
</div>
)}
{/* Escape hatch */}
<div className="space-y-5">
<div className="flex items-center gap-4">
+329 -98
View File
@@ -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<ConnectionStatus | null>(null)
const [submission, setSubmission] = useState<SubmissionState | null>(null)
const [kontroller, setKontroller] = useState<KontrollResult[]>([])
const [kontroller, setKontroller] = useState<KontrollFinding[]>([])
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [error, setError] = useState<string | null>(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<ReturnType<typeof setTimeout>[]>([])
useEffect(() => {
return () => {
for (const t of kvittensTimers.current) clearTimeout(t)
kvittensTimers.current = []
}
}, [])
/**
* Background-poll /agi/kvittenser at 30s, 2 min, and 5 min after the user
* receives a signing link. The kvittenser handler in the extension stamps
* salary_runs.agi_submitted_at when it observes a uuidKvittens, so this
* gives us a high-probability confirmation without depending on the user
* returning to the panel and clicking "Hämta kvittens" — which is critical
* for the audit trail (BFL 5 kap / BFNAR 2013:2): a NULL agi_submitted_at
* after a real filing would misrepresent the behandlingshistorik.
*
* Each poll silently refreshes local submission state on success and
* stops scheduling further polls once a kvittens is observed.
*/
const scheduleKvittensPolls = useCallback(() => {
for (const t of kvittensTimers.current) clearTimeout(t)
kvittensTimers.current = []
const poll = async () => {
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/agi/kvittenser?arbetsgivare=${encodeURIComponent(arbetsgivare)}&period=${period}`,
)
if (!res.ok) return
const json = await res.json()
const signed = !!json.data?.kvittenser?.[0]?.uuidKvittens
await fetchSubmission()
if (signed) {
// Cancel any remaining timers — the kvittens has been recorded
// server-side and further polls are wasted requests.
for (const t of kvittensTimers.current) clearTimeout(t)
kvittensTimers.current = []
onChange?.()
}
} catch {
// Silent: this is a background helper. The "Hämta kvittens" button
// remains the explicit recovery path.
}
}
kvittensTimers.current.push(setTimeout(poll, 30_000))
kvittensTimers.current.push(setTimeout(poll, 120_000))
kvittensTimers.current.push(setTimeout(poll, 300_000))
}, [arbetsgivare, period, fetchSubmission, onChange])
const 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 (
<Card>
@@ -319,6 +509,29 @@ export function AGIPanel(props: AGIPanelProps) {
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Missing-scope banner — proactive nudge before the user hits a
403 invalid_scope at submission time. The agd scope was added
after some users had already connected, so their stored token
grants moms/skattekonto but not AGI. */}
{missingAgdScope && !readOnly && (
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 dark:border-amber-900/40 dark:bg-amber-900/20">
<p className="text-sm font-medium">
Anslutningen mot Skatteverket saknar behörighet för Arbetsgivardeklaration
</p>
<p className="mt-1 text-xs text-muted-foreground">
Din anslutning utfärdades innan AGI-stödet aktiverades. Koppla
bort och anslut igen via Inställningar → Skatteverket för att
kunna skicka AGI direkt.
</p>
<a
href="/settings/skatteverket"
className="mt-2 inline-flex items-center gap-1 text-sm font-medium hover:underline"
>
Öppna inställningar <ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
)}
{/* Status summary */}
<div className="space-y-1.5 text-sm">
<StatusRow
@@ -336,16 +549,21 @@ export function AGIPanel(props: AGIPanelProps) {
: 'Skickad'
}
pendingText={
isLocked
? 'AGI låst — väntar på BankID-signatur.'
: subState === 'draft_saved'
? 'Utkast sparat hos Skatteverket. Lås och signera för att slutföra.'
: 'Inte skickad till Skatteverket ännu. Deadline: 12:e i månaden efter utbetalning.'
awaitingSigning
? 'Granskningsunderlag klart — väntar på BankID-signatur i Mina Sidor.'
: underlagSubmitted
? 'Underlag inläst hos Skatteverket. Skapa granskningsunderlag för att gå vidare till signering.'
: 'Inte skickad till Skatteverket ännu. Deadline: 12:e i månaden efter utbetalning (17:e i januari/augusti för arbetsgivare vars sammanlagda lönesumma understiger 40 MSEK per år).'
}
/>
</div>
{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 && (
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 dark:border-amber-900/40 dark:bg-amber-900/20">
<p className="text-sm font-medium">Utkastet är låst och redo att signeras</p>
<p className="mt-0.5 text-xs text-muted-foreground">
@@ -362,18 +580,44 @@ export function AGIPanel(props: AGIPanelProps) {
</div>
)}
{/* INCORRECT_DATA branch — skapaGranskningsunderlag returned 409 with
a felrapport link. The user must open the link in Mina Sidor to
see what's wrong, fix it, and then re-submit. Without this UI the
link would be permanently unreachable even though the extension
persisted it. */}
{submission?.signeringslank && underlagRejected && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 p-3">
<p className="text-sm font-medium text-destructive">
Felaktiga underlag — granskningsunderlag kunde inte signeras
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{submission.meddelande || 'Skatteverket avvisade underlaget. Öppna felrapporten för detaljer.'}
</p>
<a
href={submission.signeringslank}
target="_blank"
rel="noreferrer"
className="mt-2 inline-flex items-center gap-1 text-sm font-medium text-destructive hover:underline"
>
Öppna felrapport hos Skatteverket <ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
)}
{kontroller.length > 0 && (
<div className="space-y-1 rounded-md border bg-muted/30 p-2.5">
{kontroller.map((k, i) => (
<div
key={i}
className={`flex items-start gap-2 text-xs ${
k.status === 'ERROR' ? 'text-destructive' : 'text-amber-700 dark:text-amber-400'
k.status === 'STOPP' ? 'text-destructive' : 'text-amber-700 dark:text-amber-400'
}`}
>
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
<span className="font-mono">{k.kod}</span> — {k.beskrivning}
{k.kod && <span className="font-mono">{k.kod} </span>}
{k.uppgiftsTyp && <span className="text-muted-foreground">[{k.uppgiftsTyp}{k.specifikationsnummer ? ` #${k.specifikationsnummer}` : ''}] </span>}
{k.beskrivning}
</span>
</div>
))}
@@ -398,43 +642,30 @@ export function AGIPanel(props: AGIPanelProps) {
<Button
size="sm"
variant="outline"
onClick={handleValidate}
disabled={!!actionLoading}
onClick={handleSubmit}
disabled={!!actionLoading || awaitingSigning}
>
{actionLoading === 'validate' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<FileCheck className="mr-1.5 h-3.5 w-3.5" />
)}
Validera
</Button>
<Button
size="sm"
variant="outline"
onClick={handleSaveDraft}
disabled={!!actionLoading || isLocked}
>
{actionLoading === 'draft' ? (
{actionLoading === 'submit' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Send className="mr-1.5 h-3.5 w-3.5" />
)}
Spara utkast
Skicka in underlag
</Button>
{!isLocked ? (
<Button
size="sm"
onClick={handleLock}
disabled={!!actionLoading || subState !== 'draft_saved'}
>
{actionLoading === 'lock' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Lock className="mr-1.5 h-3.5 w-3.5" />
)}
Lås för signering
</Button>
) : (
<Button
size="sm"
onClick={handleCreateSigningLink}
disabled={!!actionLoading || (!underlagSubmitted && !awaitingSigning)}
title={!underlagSubmitted && !awaitingSigning ? 'Skicka in underlag först' : ''}
>
{actionLoading === 'granskning' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Lock className="mr-1.5 h-3.5 w-3.5" />
)}
Skapa signeringslänk
</Button>
{awaitingSigning && (
<Button
size="sm"
variant="outline"
@@ -446,7 +677,7 @@ export function AGIPanel(props: AGIPanelProps) {
) : (
<Unlock className="mr-1.5 h-3.5 w-3.5" />
)}
Lås upp
Lås upp period
</Button>
)}
<Button
@@ -460,7 +691,7 @@ export function AGIPanel(props: AGIPanelProps) {
) : (
<Download className="mr-1.5 h-3.5 w-3.5" />
)}
Hämta status
Hämta kvittens
</Button>
</div>
)}
@@ -23,6 +23,7 @@ const SCOPE_LABELS: Record<string, string> = {
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.
</p>
)}
{!scopes.includes('agd') && (
<p className="mt-3 text-sm text-foreground">
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.
</p>
)}
</div>
<div className="flex gap-2 pt-2">
{(status.expired || !status.canRefresh || !scopes.includes('skattekonto')) && (
{(status.expired || !status.canRefresh || !scopes.includes('skattekonto') || !scopes.includes('agd')) && (
<Button onClick={startConnect}>
<ExternalLink className="mr-2 h-4 w-4" />
Anslut igen
@@ -1,185 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { buildAGIPayload } from '../lib/agi-mappers'
import type { AGIEmployeeData, AGITotals } from '@/lib/salary/agi/xml-generator'
// Mock personnummer decryption
vi.mock('@/lib/salary/personnummer', () => ({
decryptPersonnummer: vi.fn((encrypted: string) => {
// Simulate decryption: in tests, we use plaintext personnummer
if (encrypted === 'INVALID') throw new Error('Decryption failed')
return encrypted
}),
}))
function makeEmployee(overrides: Partial<AGIEmployeeData> = {}): AGIEmployeeData {
return {
personnummer: '199001011234',
specificationNumber: 1,
grossSalary: 35000,
taxWithheld: 8000,
avgifterBasis: 35000,
...overrides,
}
}
function makeTotals(overrides: Partial<AGITotals> = {}): AGITotals {
return {
totalTax: 8000,
totalAvgifterBasis: 35000,
totalAvgifterAmount: 10997,
avgifterByCategory: {
standard: { basis: 35000, amount: 10997 },
},
...overrides,
}
}
describe('buildAGIPayload', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('builds payload with correct structure', () => {
const result = buildAGIPayload([makeEmployee()], makeTotals())
expect(result).toMatchObject({
rattelse: false,
huvuduppgift: {
avdragenSkatt: 8000,
summaArbetsgivaravgifterUnderlag: 35000,
avgifterUnderlagStandard: 35000,
},
individuppgifter: [
{
personnummer: '199001011234',
specifikationsnummer: 1,
kontantBruttoloen: 35000,
avdragenSkatt: 8000,
underlagArbetsgivaravgifter: 35000,
},
],
})
})
it('sets rattelse flag for corrections', () => {
const result = buildAGIPayload([makeEmployee()], makeTotals(), true)
expect(result.rattelse).toBe(true)
})
it('omits zero-value fields from individuppgift', () => {
const emp = makeEmployee({
benefitCar: 0,
benefitMeals: undefined,
sickDays: 0,
})
const result = buildAGIPayload([emp], makeTotals())
const ind = result.individuppgifter[0]
expect(ind.formanBil).toBeUndefined()
expect(ind.formanKost).toBeUndefined()
expect(ind.sjukfranvaroDagar).toBeUndefined()
})
it('includes benefit values when present', () => {
const emp = makeEmployee({
benefitCar: 4500,
benefitHousing: 3000,
benefitMeals: 1800,
benefitOther: 500,
})
const result = buildAGIPayload([emp], makeTotals())
const ind = result.individuppgifter[0]
expect(ind.formanBil).toBe(4500)
expect(ind.formanBostad).toBe(3000)
expect(ind.formanKost).toBe(1800)
expect(ind.formanOvrigt).toBe(500)
})
it('includes absence fields when present', () => {
const emp = makeEmployee({
sickDays: 3,
vabDays: 2,
parentalDays: 5,
})
const result = buildAGIPayload([emp], makeTotals())
const ind = result.individuppgifter[0]
expect(ind.sjukfranvaroDagar).toBe(3)
expect(ind.vabDagar).toBe(2)
expect(ind.foraldraledigDagar).toBe(5)
})
it('includes F-skatt payment field', () => {
const emp = makeEmployee({ fSkattPayment: 50000 })
const result = buildAGIPayload([emp], makeTotals())
expect(result.individuppgifter[0].ersattningFSkatt).toBe(50000)
})
it('rounds all amounts to whole kronor', () => {
const emp = makeEmployee({
grossSalary: 35000.75,
taxWithheld: 8000.49,
avgifterBasis: 35000.5,
})
const result = buildAGIPayload([emp], makeTotals())
const ind = result.individuppgifter[0]
expect(ind.kontantBruttoloen).toBe(35001)
expect(ind.avdragenSkatt).toBe(8000)
expect(ind.underlagArbetsgivaravgifter).toBe(35001)
})
it('handles multiple avgifter categories', () => {
const totals = makeTotals({
avgifterByCategory: {
standard: { basis: 70000, amount: 21994 },
reduced65plus: { basis: 30000, amount: 3063 },
youth: { basis: 25000, amount: 5203 },
},
})
const result = buildAGIPayload([makeEmployee()], totals)
const hu = result.huvuduppgift
expect(hu.avgifterUnderlagStandard).toBe(70000)
expect(hu.avgifterUnderlagAlderspension).toBe(30000)
expect(hu.avgifterUnderlagUngdom).toBe(25000)
})
it('handles multiple employees', () => {
const employees = [
makeEmployee({ specificationNumber: 1, grossSalary: 35000 }),
makeEmployee({ specificationNumber: 2, personnummer: '199512152345', grossSalary: 28000 }),
]
const result = buildAGIPayload(employees, makeTotals({ totalTax: 15000, totalAvgifterBasis: 63000 }))
expect(result.individuppgifter).toHaveLength(2)
expect(result.individuppgifter[0].specifikationsnummer).toBe(1)
expect(result.individuppgifter[1].specifikationsnummer).toBe(2)
expect(result.individuppgifter[1].personnummer).toBe('199512152345')
})
it('throws if personnummer cannot be decrypted', () => {
const emp = makeEmployee({ personnummer: 'INVALID' })
expect(() => buildAGIPayload([emp], makeTotals())).toThrow(
/Kunde inte dekryptera personnummer.*FK570=1/
)
})
it('omits huvuduppgift fields when zero', () => {
const totals: AGITotals = {
totalTax: 0,
totalAvgifterBasis: 0,
totalAvgifterAmount: 0,
avgifterByCategory: {},
}
const result = buildAGIPayload([makeEmployee({ grossSalary: 0, taxWithheld: 0, avgifterBasis: 0 })], totals)
const hu = result.huvuduppgift
expect(hu.avdragenSkatt).toBeUndefined()
expect(hu.summaArbetsgivaravgifterUnderlag).toBeUndefined()
expect(hu.avgifterUnderlagStandard).toBeUndefined()
})
})
+487 -294
View File
@@ -7,12 +7,20 @@ import { storeTokens, getTokens, deleteTokens } from './lib/token-store'
import { skvRequest, SkatteverketAuthError } from './lib/api-client'
import { rutorToMomsuppgift, formatRedovisare, formatRedovisningsperiod } from './lib/mappers'
import { calculateVatDeclaration } from '@/lib/reports/vat-declaration'
import { agiSaveDraft, agiValidate, agiGetSubmission, agiDeleteDraft, agiLockPeriod, agiUnlockPeriod, agiGetSubmitted } from './lib/agi-client'
import { buildAGIPayload } from './lib/agi-mappers'
import {
agiPostUnderlag,
agiGetKontrollresultat,
agiSparaUnderlag,
agiAvbrytUnderlag,
agiTaBortSparadInlamning,
agiSkapaGranskningsunderlag,
agiGetKvittenser,
agiLasPeriod,
agiLasUppPeriod,
} from './lib/agi-client'
import { syncSkattekonto, SKATTEKONTO_BALANCE_SNAPSHOT_KEY, SKATTEKONTO_LAST_SYNCED_AT_KEY } from './lib/skattekonto-sync'
import { bokforSkattekontoTransaction, SkattekontoBookingError } from './lib/skattekonto-booking'
import type { SkattekontoBalanceSnapshot } from './types'
import type { AGIEmployeeData, AGITotals } from '@/lib/salary/agi/xml-generator'
import type { VatPeriodType } from '@/types'
/**
@@ -601,86 +609,61 @@ export const skatteverketExtension: Extension = {
},
// ══════════════════════════════════════════════════════════════
// AGI (Arbetsgivardeklaration) routes
//
// AGI submission is XML, not JSON. We feed agi_declarations.xml_content
// (built by lib/salary/agi/xml-generator.ts) to POST /underlag, then poll
// kontrollresultat, save into Eget utrymme, and return a Mina Sidor
// signing link via skapaGranskningsunderlag. After the user signs we
// observe the kvittenser endpoint to record kvittensnummer/signeradTid.
//
// The route surface mirrors the conceptual flow rather than the literal
// SKV endpoints so the frontend stays simple. Two SKV APIs are involved:
// inlamning (XML ingest + JSON status) and hanteraredovisningsperiod
// (kvittenser + las/lasUpp). The agi-client encapsulates both.
// ══════════════════════════════════════════════════════════════
// ── AGI: Validate (dry run) ────────────────────────────────────
// ── AGI: Submit (POST /underlag with stored XML) ────────────────
// Body: { salaryRunId }. Reads agi_declarations.xml_content for the run,
// posts it to Skatteverket, returns { inlamningId } so the caller can
// poll kontrollresultat. Also persists inlamningId locally for recovery.
{
method: 'POST',
path: '/agi/validate',
path: '/agi/submit',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
try {
const { arbetsgivare, period, payload } = await parseAGIRequest(request, ctx)
const { arbetsgivare, period, salaryRunId, xml } = await loadAGIXml(request, ctx)
console.log('[skatteverket] AGI validating:', { arbetsgivare, period })
const result = await agiValidate(ctx.supabase, ctx.companyId, arbetsgivare, period, payload)
console.log('[skatteverket] AGI submitting underlag:', { arbetsgivare, period })
const result = await agiPostUnderlag(ctx.supabase, ctx.userId, xml)
if (!result.ok) {
console.error('[skatteverket] AGI validate error:', result.status, result.error)
console.error('[skatteverket] AGI underlag error:', result.status, result.error)
return NextResponse.json(
{ error: `Skatteverket svarade med ${result.status}: ${result.error}` },
{ status: result.status }
{ error: result.error, code: result.body?.kod },
{ status: result.status },
)
}
return NextResponse.json({ data: result.data })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── AGI: Save draft ────────────────────────────────────────────
{
method: 'POST',
path: '/agi/draft',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
try {
const { arbetsgivare, period, payload, salaryRunId } = await parseAGIRequest(request, ctx)
console.log('[skatteverket] AGI saving draft:', { arbetsgivare, period })
const result = await agiSaveDraft(ctx.supabase, ctx.companyId, arbetsgivare, period, payload)
if (!result.ok) {
console.error('[skatteverket] AGI draft error:', result.status, result.error)
return NextResponse.json(
{ error: `Skatteverket svarade med ${result.status}: ${result.error}` },
{ status: result.status }
)
}
// Track submission status and inlämningsId
const inlamningId = result.data?.inlamningId
await ctx.settings.set(
`agi_submission_${period}`,
JSON.stringify({
status: 'draft_saved',
status: 'underlag_submitted',
arbetsgivare,
period,
inlamningId,
salaryRunId,
kontrollresultat: result.data?.kontrollresultat,
inlamningId: result.data.inlamningId,
updatedAt: new Date().toISOString(),
})
}),
)
// Update agi_declarations table with submission status
if (salaryRunId) {
await ctx.supabase
.from('agi_declarations')
.update({ status: 'exported' })
.eq('salary_run_id', salaryRunId)
.eq('company_id', ctx.companyId)
}
// Don't flip agi_declarations.status to 'exported' here. SKV's
// kontrollresultat may still come back DONE_REJECTED, in which case
// nothing landed in Eget utrymme. The transition belongs in
// /agi/spara below, after the user (or auto-spara on success) has
// committed the underlag.
return NextResponse.json({ data: result.data })
} catch (err) {
@@ -689,35 +672,64 @@ export const skatteverketExtension: Extension = {
},
},
// ── AGI: Get submission ────────────────────────────────────────
// ── AGI: Poll kontrollresultat ──────────────────────────────────
// Query: ?inlamningId=...
// Returns { status: PROCESSING | DONE_SUCCESS | DONE_FAILED | DONE_REJECTED, ... }
//
// Side effect: when SKV reports a terminal failure (DONE_REJECTED or
// DONE_FAILED), promote the matching agi_declarations row to 'rejected'.
// Without this the row would sit at 'generated' indefinitely while SKV's
// own state shows the underlag as failed — misrepresenting the filing
// outcome (BFNAR 2013:2 kap 8 / BFL 5 kap 5§).
{
method: 'GET',
path: '/agi/submission',
path: '/agi/kontrollresultat',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
try {
const url = new URL(request.url)
const arbetsgivare = url.searchParams.get('arbetsgivare')
const period = url.searchParams.get('period')
const inlamningId = url.searchParams.get('inlamningId')
const inlamningId = Number(url.searchParams.get('inlamningId'))
if (!Number.isFinite(inlamningId) || inlamningId <= 0) {
return NextResponse.json({ error: 'Saknar parameter: inlamningId' }, { status: 400 })
}
if (!arbetsgivare || !period || !inlamningId) {
const result = await agiGetKontrollresultat(ctx.supabase, ctx.userId, inlamningId)
if (!result.ok) {
return NextResponse.json(
{ error: 'Saknar parametrar: arbetsgivare, period, inlamningId' },
{ status: 400 }
{ error: result.error, code: result.body?.kod },
{ status: result.status },
)
}
const result = await agiGetSubmission(ctx.supabase, ctx.companyId, arbetsgivare, period, inlamningId)
if (!result.ok) {
return NextResponse.json(
{ error: `Skatteverket svarade med ${result.status}: ${result.error}` },
{ status: result.status }
)
if (result.data.status === 'DONE_REJECTED' || result.data.status === 'DONE_FAILED') {
// Recover the salaryRunId from cached submission state — same
// fallback mechanism /agi/spara uses. We only update when we can
// identify the row; a missing local cache means we silently skip
// (the alternative would be guessing which declaration to mark).
const { data: rows } = await ctx.supabase
.from('extension_data')
.select('value')
.eq('company_id', ctx.companyId)
.eq('extension_id', 'skatteverket')
.like('key', 'agi_submission_%')
for (const row of rows ?? []) {
try {
const v = JSON.parse(row.value as string) as { inlamningId?: number; salaryRunId?: string }
if (v.inlamningId === inlamningId && v.salaryRunId) {
// Same monotonicity rule as /agi/spara — never regress
// from a successful filing back to 'rejected'.
await ctx.supabase
.from('agi_declarations')
.update({ status: 'rejected' })
.eq('salary_run_id', v.salaryRunId)
.eq('company_id', ctx.companyId)
.in('status', ['generated', 'pending_signature', 'exported'])
break
}
} catch { /* skip malformed */ }
}
}
return NextResponse.json({ data: result.data })
@@ -727,37 +739,193 @@ export const skatteverketExtension: Extension = {
},
},
// ── AGI: Delete draft ──────────────────────────────────────────
// ── AGI: Save underlag into Eget utrymme ────────────────────────
// Body: { inlamningId, salaryRunId? }. Only meaningful between
// POST /underlag and skapaGranskningsunderlag.
//
// Flips agi_declarations.status to 'pending_signature' on success —
// the underlag is durable in SKV's Eget utrymme but is not yet a
// filed declaration. /agi/kvittenser later promotes it to 'submitted'
// when a uuidKvittens (signature receipt) is observed for the period.
// /agi/submit deliberately does NOT update status, because a
// DONE_REJECTED kontrollresultat would leave it falsely pending.
{
method: 'DELETE',
path: '/agi/draft',
method: 'POST',
path: '/agi/spara',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
try {
const body = (await request.json()) as { inlamningId?: number; salaryRunId?: string }
const inlamningId = Number(body.inlamningId)
if (!Number.isFinite(inlamningId) || inlamningId <= 0) {
return NextResponse.json({ error: 'Saknar inlamningId' }, { status: 400 })
}
const result = await agiSparaUnderlag(ctx.supabase, ctx.userId, inlamningId)
if (!result.ok) {
return NextResponse.json(
{ error: result.error, code: result.body?.kod },
{ status: result.status },
)
}
// Promote the matching declaration to 'exported'. salaryRunId is
// accepted in the body for the happy path; if missing we can still
// fall back to the locally-cached submission state, which always
// carries it (we wrote it in /agi/submit).
let runId = body.salaryRunId
if (!runId) {
// Last-resort lookup: scan recent agi_submission_* keys for a
// matching inlamningId. Cheap because there's at most one active
// submission per period and the operator typically has very few.
const { data: rows } = await ctx.supabase
.from('extension_data')
.select('value')
.eq('company_id', ctx.companyId)
.eq('extension_id', 'skatteverket')
.like('key', 'agi_submission_%')
for (const row of rows ?? []) {
try {
const v = JSON.parse(row.value as string) as { inlamningId?: number; salaryRunId?: string }
if (v.inlamningId === inlamningId && v.salaryRunId) {
runId = v.salaryRunId
break
}
} catch { /* skip malformed */ }
}
}
if (runId) {
// Monotonicity guard: only flip from a pre-filing state. If the
// kvittens cron or the interactive /agi/kvittenser handler has
// already promoted this row to 'submitted'/'accepted', don't
// regress it — behandlingshistorik must move forward through
// the filing milestones (BFNAR 2013:2 kap 8).
//
// 'rejected' IS allowed as an originating state: a previous
// submission failed kontrollresultat, the user fixed the XML
// and re-submitted. The same agi_declarations row is reused
// (xml-route updates xml_content in place), so this update
// promotes the recovered submission back to pending_signature.
//
// 'exported' is NOT in the allowed-from list. The status value
// is preserved in the schema for the legacy manual-download
// path (see migration), but no code currently writes it; an
// 'exported' row encountered here would represent a parallel
// filing attempt that should land in its own row, not reuse
// this one (preserves chain of custody per BFL 5 kap 6§).
await ctx.supabase
.from('agi_declarations')
.update({ status: 'pending_signature' })
.eq('salary_run_id', runId)
.eq('company_id', ctx.companyId)
.in('status', ['generated', 'rejected'])
}
return NextResponse.json({ data: result.data })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── AGI: Avbryt underlag (before spara) ─────────────────────────
// Query: ?inlamningId=...&period=YYYYMM (period optional but recommended)
//
// The `period` param lets the handler clear the locally-cached
// `agi_submission_{period}` record so the UI doesn't sit on a stale
// `underlag_submitted` state. If the caller doesn't pass it we fall
// back to scanning recent submission keys for the matching inlamningId.
{
method: 'DELETE',
path: '/agi/underlag',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
try {
const url = new URL(request.url)
const inlamningId = Number(url.searchParams.get('inlamningId'))
const period = url.searchParams.get('period')
if (!Number.isFinite(inlamningId) || inlamningId <= 0) {
return NextResponse.json({ error: 'Saknar parameter: inlamningId' }, { status: 400 })
}
const result = await agiAvbrytUnderlag(ctx.supabase, ctx.userId, inlamningId)
if (!result.ok) {
return NextResponse.json(
{ error: result.error, code: result.body?.kod },
{ status: result.status },
)
}
// Clear locally-cached submission state so the UI doesn't keep
// showing `underlag_submitted` for an inlamning that no longer
// exists at SKV. Direct path: caller passed period.
if (period) {
await ctx.settings.set(`agi_submission_${period}`, null)
} else {
// Fallback: find the period by matching inlamningId across
// recent submission keys. Cheap because there's at most one
// active submission per period.
const { data: rows } = await ctx.supabase
.from('extension_data')
.select('key, value')
.eq('company_id', ctx.companyId)
.eq('extension_id', 'skatteverket')
.like('key', 'agi_submission_%')
for (const row of rows ?? []) {
try {
const v = JSON.parse(row.value as string) as { inlamningId?: number }
if (v.inlamningId === inlamningId) {
await ctx.supabase
.from('extension_data')
.delete()
.eq('company_id', ctx.companyId)
.eq('extension_id', 'skatteverket')
.eq('key', row.key as string)
break
}
} catch { /* skip malformed */ }
}
}
return NextResponse.json({ success: true })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── AGI: Ta bort sparad inlämning (after spara) ─────────────────
// Query: ?arbetsgivare=...&period=YYYYMM&inlamningId=...
{
method: 'DELETE',
path: '/agi/sparad',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
try {
const url = new URL(request.url)
const arbetsgivare = url.searchParams.get('arbetsgivare')
const period = url.searchParams.get('period')
const inlamningId = url.searchParams.get('inlamningId')
if (!arbetsgivare || !period || !inlamningId) {
const inlamningId = Number(url.searchParams.get('inlamningId'))
if (!arbetsgivare || !period || !Number.isFinite(inlamningId) || inlamningId <= 0) {
return NextResponse.json(
{ error: 'Saknar parametrar: arbetsgivare, period, inlamningId' },
{ status: 400 }
{ status: 400 },
)
}
const result = await agiDeleteDraft(ctx.supabase, ctx.companyId, arbetsgivare, period, inlamningId)
const result = await agiTaBortSparadInlamning(
ctx.supabase, ctx.userId, arbetsgivare, period, inlamningId,
)
if (!result.ok) {
return NextResponse.json(
{ error: `Skatteverket svarade med ${result.status}: ${result.error}` },
{ status: result.status }
{ error: result.error, code: result.body?.kod },
{ status: result.status },
)
}
await ctx.settings.set(`agi_submission_${period}`, null)
return NextResponse.json({ success: true })
} catch (err) {
@@ -766,45 +934,64 @@ export const skatteverketExtension: Extension = {
},
},
// ── AGI: Lock period for signing ───────────────────────────────
// ── AGI: Skapa granskningsunderlag (BankID signing link) ────────
// Query: ?arbetsgivare=...&period=YYYYMM&lasPeriod=true|false
// Returns { link, tillstand, meddelande }. The user opens `link` in a
// new tab and signs with BankID on Skatteverket's site.
{
method: 'PUT',
path: '/agi/lock',
method: 'POST',
path: '/agi/granskningsunderlag',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
try {
const url = new URL(request.url)
const arbetsgivare = url.searchParams.get('arbetsgivare')
const period = url.searchParams.get('period')
const lasPeriod = url.searchParams.get('lasPeriod') !== 'false' // default true
if (!arbetsgivare || !period) {
return NextResponse.json(
{ error: 'Saknar parametrar: arbetsgivare, period' },
{ status: 400 }
{ status: 400 },
)
}
const result = await agiLockPeriod(ctx.supabase, ctx.companyId, arbetsgivare, period)
const result = await agiSkapaGranskningsunderlag(
ctx.supabase, ctx.userId, arbetsgivare, period, { lasPeriod },
)
if (!result.ok) {
return NextResponse.json(
{ error: `Skatteverket svarade med ${result.status}: ${result.error}` },
{ status: result.status }
{ error: result.error, code: result.body?.kod },
{ status: result.status },
)
}
// Persist the link so the user can return to it after a refresh.
// SKV's tillstand enum (per skapagranskningsunderlagsvar.json):
// LOCKED_FOR_SIGNING / UNLOCKED → granskning ready, user can sign
// INCORRECT_DATA → felrapport link, can't sign yet
// RECEIVING / CALCULATING → server still processing
// SIGNING → another signing flow already running
// We key on tillstand alone — keying on HTTP status (e.g. 409) and
// the body string would miss future SKV additions like RECEIVING
// returned with HTTP 200, leaving us in an awaiting_signing state
// when the underlag isn't actually ready.
const canSign =
result.data.tillstand === 'LOCKED_FOR_SIGNING' ||
result.data.tillstand === 'UNLOCKED'
await ctx.settings.set(
`agi_submission_${period}`,
JSON.stringify({
status: 'draft_locked',
status: canSign ? 'awaiting_signing' : 'underlag_rejected',
arbetsgivare,
period,
signeringslank: result.data?.signeringslank,
signeringslank: result.data.link,
tillstand: result.data.tillstand,
meddelande: result.data.meddelande,
updatedAt: new Date().toISOString(),
})
}),
)
return NextResponse.json({ data: result.data })
@@ -814,112 +1001,117 @@ export const skatteverketExtension: Extension = {
},
},
// ── AGI: Unlock period ─────────────────────────────────────────
{
method: 'DELETE',
path: '/agi/lock',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
try {
const url = new URL(request.url)
const arbetsgivare = url.searchParams.get('arbetsgivare')
const period = url.searchParams.get('period')
if (!arbetsgivare || !period) {
return NextResponse.json(
{ error: 'Saknar parametrar: arbetsgivare, period' },
{ status: 400 }
)
}
const result = await agiUnlockPeriod(ctx.supabase, ctx.companyId, arbetsgivare, period)
if (!result.ok) {
return NextResponse.json(
{ error: `Skatteverket svarade med ${result.status}: ${result.error}` },
{ status: result.status }
)
}
await ctx.settings.set(
`agi_submission_${period}`,
JSON.stringify({
status: 'draft_saved',
arbetsgivare,
period,
updatedAt: new Date().toISOString(),
})
)
return NextResponse.json({ success: true })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── AGI: Fetch submitted (after BankID signing) ────────────────
// ── AGI: Hämta kvittenser (after user signs) ────────────────────
// Query: ?arbetsgivare=...&period=YYYYMM
// Returns the kvittenser array. While the user has not yet signed the
// array is empty; after signing it carries uuidKvittens/signeradAv/-Tid.
{
method: 'GET',
path: '/agi/submitted',
path: '/agi/kvittenser',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
try {
const url = new URL(request.url)
const arbetsgivare = url.searchParams.get('arbetsgivare')
const period = url.searchParams.get('period')
if (!arbetsgivare || !period) {
return NextResponse.json(
{ error: 'Saknar parametrar: arbetsgivare, period' },
{ status: 400 }
{ status: 400 },
)
}
const result = await agiGetSubmitted(ctx.supabase, ctx.companyId, arbetsgivare, period)
const result = await agiGetKvittenser(ctx.supabase, ctx.userId, arbetsgivare, period)
if (!result.ok) {
return NextResponse.json(
{ error: `Skatteverket svarade med ${result.status}: ${result.error}` },
{ status: result.status }
{ error: result.error, code: result.body?.kod },
{ status: result.status },
)
}
// If we got a kvittensnummer, the AGI has been signed and submitted
if (result.data?.kvittensnummer) {
// Newest kvittens for the period drives the local state.
const kvittens = result.data.kvittenser?.[0]
if (kvittens?.uuidKvittens) {
const periodYear = parseInt(period.slice(0, 4))
const periodMonth = parseInt(period.slice(4, 6))
await ctx.settings.set(
`agi_submission_${period}`,
JSON.stringify({
status: 'signed',
arbetsgivare,
period,
kvittensnummer: result.data.kvittensnummer,
tidpunkt: result.data.tidpunkt,
signerare: result.data.signerare,
kvittensnummer: kvittens.uuidKvittens,
signeradAv: kvittens.signeradAv,
signeradTid: kvittens.signeradTid,
updatedAt: new Date().toISOString(),
})
}),
)
// Update agi_declarations with submission receipt
const periodYear = parseInt(period.slice(0, 4))
const periodMonth = parseInt(period.slice(4, 6))
await ctx.supabase
.from('agi_declarations')
.update({
status: 'submitted',
kvittensnummer: result.data.kvittensnummer,
submitted_at: result.data.tidpunkt || new Date().toISOString(),
submitted_by: ctx.userId,
// Pin the receipt to the most recent declaration for this period
// (id desc) so a correction chain doesn't get its kvittens written
// onto a superseded row. Also stamp salary_runs.agi_submitted_at
// here, mirroring SKV's signeradTid — this is the only place we
// know the AGI was actually filed (the orchestrator deliberately
// doesn't stamp on underlag-ingest, see route.ts comment).
//
// 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 the behandlings-
// historik (BFNAR 2013:2 kap 8 / BFL 5 kap 6§). The fallback
// applies only on this code path because we're inside the
// `if (kvittens?.uuidKvittens)` branch — if no kvittens, no stamp.
const submittedAt = kvittens.signeradTid || new Date().toISOString()
if (!kvittens.signeradTid) {
console.warn('[skatteverket] kvittens missing signeradTid; using reconciliation time', {
companyId: ctx.companyId, period, uuidKvittens: kvittens.uuidKvittens,
})
}
const { data: latest } = await ctx.supabase
.from('agi_declarations')
.select('id, salary_run_id')
.eq('company_id', ctx.companyId)
.eq('period_year', periodYear)
.eq('period_month', periodMonth)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
if (latest?.id) {
// submitted_by is the auth.users UUID we have on hand (the
// operator who polled the kvittens endpoint). The actual
// BankID signer is identified by kvittens.signeradAv (a
// personnummer string), which we preserve in response_data
// alongside the rest of the receipt — that's the legally
// load-bearing audit record per BFL 5 kap 6§.
await ctx.supabase
.from('agi_declarations')
.update({
status: 'submitted',
kvittensnummer: kvittens.uuidKvittens,
submitted_at: submittedAt,
submitted_by: ctx.userId,
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,
},
})
.eq('id', latest.id)
if (latest.salary_run_id) {
await ctx.supabase
.from('salary_runs')
.update({ agi_submitted_at: submittedAt })
.eq('id', latest.salary_run_id)
.eq('company_id', ctx.companyId)
}
}
}
return NextResponse.json({ data: result.data })
@@ -929,27 +1121,82 @@ export const skatteverketExtension: Extension = {
},
},
// ── AGI: Get submission status (local tracking) ────────────────
// ── AGI: Lås period ─────────────────────────────────────────────
// Hantera-API; typically not needed (skapaGranskningsunderlag already
// accepts lasPeriod=true). Exposed for recovery / manual control.
{
method: 'POST',
path: '/agi/las',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
try {
const url = new URL(request.url)
const arbetsgivare = url.searchParams.get('arbetsgivare')
const period = url.searchParams.get('period')
if (!arbetsgivare || !period) {
return NextResponse.json(
{ error: 'Saknar parametrar: arbetsgivare, period' },
{ status: 400 },
)
}
const result = await agiLasPeriod(ctx.supabase, ctx.userId, arbetsgivare, period)
if (!result.ok) {
return NextResponse.json(
{ error: result.error, code: result.body?.kod },
{ status: result.status },
)
}
return NextResponse.json({ data: result.data })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── AGI: Lås upp period ─────────────────────────────────────────
{
method: 'POST',
path: '/agi/lasUpp',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
try {
const url = new URL(request.url)
const arbetsgivare = url.searchParams.get('arbetsgivare')
const period = url.searchParams.get('period')
if (!arbetsgivare || !period) {
return NextResponse.json(
{ error: 'Saknar parametrar: arbetsgivare, period' },
{ status: 400 },
)
}
const result = await agiLasUppPeriod(ctx.supabase, ctx.userId, arbetsgivare, period)
if (!result.ok) {
return NextResponse.json(
{ error: result.error, code: result.body?.kod },
{ status: result.status },
)
}
return NextResponse.json({ data: result.data })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── AGI: Local submission tracking (UI helper) ──────────────────
// Returns the locally-cached submission state (inlamningId, signing link,
// kvittensnummer if seen). Pure read; never calls Skatteverket.
{
method: 'GET',
path: '/agi/status',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
if (!ctx) return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
const url = new URL(request.url)
const period = url.searchParams.get('period')
if (!period) {
return NextResponse.json({ error: 'Saknar parameter: period' }, { status: 400 })
}
if (!period) return NextResponse.json({ error: 'Saknar parameter: period' }, { status: 400 })
const statusJson = await ctx.settings.get<string>(`agi_submission_${period}`)
if (!statusJson) {
return NextResponse.json({ data: null })
}
if (!statusJson) return NextResponse.json({ data: null })
try {
return NextResponse.json({ data: JSON.parse(statusJson) })
} catch {
@@ -1160,26 +1407,51 @@ function parseQueryParams(
}
/**
* Parse and validate AGI submission request body.
* Loads salary run data and builds the Skatteverket AGI JSON payload.
* Load the AGI XML for a salary run from agi_declarations.xml_content
* (built by app/api/salary/runs/[id]/agi/xml/route.ts via generateAGIXml).
*
* Returns the XML alongside the formatted arbetsgivare/period strings used
* downstream by the granskningsunderlag and kvittenser endpoints.
*
* Skatteverket's POST /underlag accepts XML directly; we don't transform it
* here, just plumb it through.
*/
async function parseAGIRequest(
async function loadAGIXml(
request: Request,
ctx: ExtensionContext
ctx: ExtensionContext,
): Promise<{
arbetsgivare: string
period: string
payload: ReturnType<typeof buildAGIPayload>
salaryRunId: string
xml: string
}> {
const body = await request.json()
const { salaryRunId } = body as { salaryRunId: string }
const body = (await request.json()) as { salaryRunId?: string }
const salaryRunId = body.salaryRunId
if (!salaryRunId) {
throw new Error('Saknar obligatoriskt fält: salaryRunId')
}
// Get company settings for arbetsgivare formatting
// Status guard — must mirror the orchestrator at
// app/api/salary/runs/[id]/agi/submit/route.ts. The extension endpoint
// is also reachable directly from AGIPanel, so the check has to live here
// too. Per BFL 5 kap and SFL 26 kap, AGI must reflect finalised payroll
// data; submitting from a draft/cancelled run would emit incorrect figures
// and require a costly rättelse.
const { data: run, error: runError } = await ctx.supabase
.from('salary_runs')
.select('status')
.eq('id', salaryRunId)
.eq('company_id', ctx.companyId)
.single()
if (runError || !run) {
throw new Error('Lönekörning hittades inte')
}
if (!['review', 'approved', 'paid', 'booked'].includes(run.status)) {
throw new Error('AGI kan bara skickas till Skatteverket efter granskning')
}
const { data: settings } = await ctx.supabase
.from('company_settings')
.select('org_number, entity_type')
@@ -1190,113 +1462,28 @@ async function parseAGIRequest(
throw new Error('Organisationsnummer saknas i företagsinställningar')
}
// Load salary run
const { data: run, error: runError } = await ctx.supabase
.from('salary_runs')
.select('*')
.eq('id', salaryRunId)
.eq('company_id', ctx.companyId)
.single()
if (runError || !run) {
throw new Error('Lönekörning hittades inte')
}
if (!['review', 'approved', 'paid', 'booked'].includes(run.status)) {
throw new Error('AGI kan bara skickas efter granskning')
}
// Load employees with their data
const { data: runEmployees } = await ctx.supabase
.from('salary_run_employees')
.select('*, employee:employees(personnummer, specification_number, f_skatt_status), line_items:salary_line_items(*)')
.eq('salary_run_id', salaryRunId)
if (!runEmployees || runEmployees.length === 0) {
throw new Error('Inga anställda i lönekörningen')
}
// Build employee data
const employeeData: AGIEmployeeData[] = runEmployees.map(sre => {
const emp = sre.employee as { personnummer: string; specification_number: number; f_skatt_status: string } | null
const lineItems = (sre.line_items || []) as Array<Record<string, unknown>>
const sumByType = (types: string[]) =>
lineItems
.filter(li => types.includes(li.item_type as string))
.reduce((sum, li) => sum + ((li.amount as number) || 0), 0)
return {
personnummer: emp?.personnummer || '',
specificationNumber: emp?.specification_number || 0,
grossSalary: sre.gross_salary,
taxWithheld: sre.tax_withheld,
avgifterBasis: sre.avgifter_basis,
fSkattPayment: emp?.f_skatt_status === 'f_skatt' ? sre.gross_salary : undefined,
benefitCar: sumByType(['benefit_car']) || undefined,
benefitHousing: sumByType(['benefit_housing']) || undefined,
benefitMeals: sumByType(['benefit_meals']) || undefined,
benefitOther: sumByType(['benefit_wellness', 'benefit_other']) || undefined,
sickDays: sre.sick_days > 0 ? sre.sick_days : undefined,
vabDays: sre.vab_days > 0 ? sre.vab_days : undefined,
parentalDays: sre.parental_days > 0 ? sre.parental_days : undefined,
}
})
// Build totals with avgifter breakdown by category
const avgifterByCategory: AGITotals['avgifterByCategory'] = {}
for (const sre of runEmployees) {
const dbCategory = sre.avgifter_category as string | null
const category = dbCategory
? (dbCategory === 'reduced_65plus' ? 'reduced65plus' : dbCategory === 'vaxa_stod' ? 'standard' : dbCategory)
: (sre.avgifter_rate <= 0.1022 ? 'reduced65plus' : sre.avgifter_rate <= 0.2082 ? 'youth' : 'standard')
const cat = avgifterByCategory[category as keyof typeof avgifterByCategory] || { basis: 0, amount: 0 }
cat.basis += sre.avgifter_basis
cat.amount += sre.avgifter_amount
;(avgifterByCategory as Record<string, { basis: number; amount: number }>)[category] = cat
}
const totalAvgifterAmount = Object.values(avgifterByCategory).reduce(
(sum, cat) => sum + (cat?.amount ?? 0),
0
)
// FK499 — sjuklönekostnad summed from sick_day2_14 line items
let totalSjuklonekostnad = 0
for (const sre of runEmployees) {
const lineItems = (sre.line_items || []) as Array<Record<string, unknown>>
for (const li of lineItems) {
if (li.item_type === 'sick_day2_14') {
totalSjuklonekostnad += Math.abs((li.amount as number) || 0)
}
}
}
const totals: AGITotals = {
totalTax: run.total_tax,
totalAvgifterBasis: runEmployees.reduce((s: number, e: { avgifter_basis: number }) => s + e.avgifter_basis, 0),
totalAvgifterAmount: Math.round(totalAvgifterAmount * 100) / 100,
totalSjuklonekostnad: Math.round(totalSjuklonekostnad * 100) / 100,
avgifterByCategory,
}
// Check if this is a correction
const { data: existingAgi } = await ctx.supabase
// Use the most recent agi_declarations row for this salary run — covers
// both new declarations and corrections (which overwrite xml_content
// in place per the existing /api/salary/runs/[id]/agi/xml route).
const { data: declaration, error: declarationError } = await ctx.supabase
.from('agi_declarations')
.select('id, status')
.select('xml_content, period_year, period_month')
.eq('company_id', ctx.companyId)
.eq('period_year', run.period_year)
.eq('period_month', run.period_month)
.in('status', ['submitted', 'accepted'])
.single()
.eq('salary_run_id', salaryRunId)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
const isCorrection = !!existingAgi
if (declarationError || !declaration?.xml_content) {
throw new Error(
'AGI-XML saknas. Generera AGI-filen från lönekörningen först (Lön → AGI → Generera).',
)
}
const arbetsgivare = formatRedovisare(settings.org_number, settings.entity_type)
const period = formatRedovisningsperiod('monthly', run.period_year, run.period_month)
const payload = buildAGIPayload(employeeData, totals, isCorrection)
const period = formatRedovisningsperiod('monthly', declaration.period_year, declaration.period_month)
return { arbetsgivare, period, payload, salaryRunId }
return { arbetsgivare, period, salaryRunId, xml: declaration.xml_content }
}
/**
@@ -1304,9 +1491,15 @@ async function parseAGIRequest(
*/
function handleSkvError(err: unknown): NextResponse {
if (err instanceof SkatteverketAuthError) {
// MISSING_SCOPE returns 401 — the existing token works, but it doesn't
// grant access to this resource. Treating it as 401 (rather than 403)
// signals to the frontend that the right remediation is to reconnect,
// not to ask the user to gain new authorization at SKV.
const status = err.code === 'NOT_CONNECTED' ? 401
: err.code === 'BEHORIGHET_SAKNAS' ? 403
: err.code === 'SESSION_EXPIRED' || err.code === 'REFRESH_EXHAUSTED' ? 401
: err.code === 'MISSING_SCOPE' ? 401
: err.code === 'TOKEN_CORRUPTED' ? 401
: 403
return NextResponse.json(
+259 -131
View File
@@ -1,229 +1,357 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { skvRequest } from './api-client'
import type { SkatteverketAGIInlamning, SkatteverketAGIKontrollresultat } from '../types'
import type {
SkatteverketAGIErrorBody,
SkatteverketAGIGranskningsunderlagResponse,
SkatteverketAGIKontrollresultat,
SkatteverketAGIKvittenserResponse,
SkatteverketAGIUnderlagResponse,
} from '../types'
/**
* Skatteverket AGI (Arbetsgivardeklaration) API client.
* Skatteverket AGI (Arbetsgivardeklaration) client.
*
* Follows the same pattern as the Momsdeklaration API:
* kontrollera → utkast → lås → (BankID signering) → inlämnat
* Two RAMLs back this surface:
* • inlamning v1.7.7 — XML ingest + JSON status reads
* base: /arbetsgivardeklaration/inlamning/v1
* • hanteraredovisningsperiod v1.2.8 — period lock + receipts
* base: /arbetsgivardeklaration/hanteraredovisningsperiod/v1
*
* Base URL: https://api.skatteverket.se/arbetsgivardeklaration/inlamning/v1
* Filing flow:
* 1. POST /underlag (XML body, returns inlamningId)
* 2. GET /underlag/{inlamningId}/kontrollresultat (poll until status != PROCESSING)
* 3a. POST /underlag/{inlamningId}/spara (move into Eget utrymme)
* 3b. DELETE /underlag/{inlamningId} (abort if user wants to retry)
* 4. POST /arbetsgivare/{x}/redovisningsperioder/{y}/skapaGranskningsunderlag?lasPeriod=true
* → returns Mina Sidor deep-link for BankID signing
* 5. GET /arbetsgivare/{x}/redovisningsperioder/{y}/kvittenser (after user signs)
*
* Endpoint pattern:
* /arbetsgivare/{arbetsgivarregistrerad}/redovisningsperioder/{redovisningsperiod}/...
* Optional period management on the hantera API:
* POST /arbetsgivare/{x}/redovisningsperioder/{y}/las (lock)
* POST /arbetsgivare/{x}/redovisningsperioder/{y}/lasUpp (unlock)
*
* Cleanup paths (both on the inlämning API, NOT hantera):
* DELETE /underlag/{inlamningId}
* — abort an unsaved underlag (use agiAvbrytUnderlag)
* DELETE /arbetsgivare/{x}/redovisningsperioder/{y}/inlamningar/{inlamningId}
* — remove a SAVED underlag from Eget utrymme (use agiTaBortSparadInlamning)
*
* Note: skvRequest already maps 401/403/429 to SkatteverketAuthError. AGI
* 400/404/409 carry the SkatteverketAGIErrorBody envelope, which we surface
* via Result.error so callers can render meddelandeTillAnvandare verbatim.
*/
const DEFAULT_AGI_API_BASE_URL =
const DEFAULT_INLAMNING_BASE_URL =
'https://api.test.skatteverket.se/arbetsgivardeklaration/inlamning/v1'
const DEFAULT_HANTERA_BASE_URL =
'https://api.test.skatteverket.se/arbetsgivardeklaration/hanteraredovisningsperiod/v1'
function getAgiApiBaseUrl(): string {
return process.env.SKATTEVERKET_AGI_API_BASE_URL || DEFAULT_AGI_API_BASE_URL
function getInlamningBaseUrl(): string {
return process.env.SKATTEVERKET_AGD_INLAMNING_API_BASE_URL || DEFAULT_INLAMNING_BASE_URL
}
function basePath(arbetsgivare: string, period: string): string {
function getHanteraBaseUrl(): string {
return process.env.SKATTEVERKET_AGD_PERIOD_API_BASE_URL || DEFAULT_HANTERA_BASE_URL
}
function periodPath(arbetsgivare: string, period: string): string {
return `/arbetsgivare/${arbetsgivare}/redovisningsperioder/${period}`
}
interface Ok<T> { ok: true; status: number; data: T }
interface Err { ok: false; status: number; error: string; body?: SkatteverketAGIErrorBody }
type Result<T> = Ok<T> | Err
async function readErrorBody(response: Response): Promise<{ error: string; body?: SkatteverketAGIErrorBody }> {
try {
const body = (await response.json()) as SkatteverketAGIErrorBody
if (body && typeof body.meddelandeTillAnvandare === 'string') {
return { error: body.meddelandeTillAnvandare, body }
}
return { error: JSON.stringify(body) }
} catch {
return { error: `Skatteverket svarade med ${response.status}` }
}
}
/**
* Validate AGI data (dry run) without saving.
* Returns validation errors/warnings.
* POST /underlag — XML body, returns the new inlämningsId.
*
* The xml is whatever generateAGIXml() produced. We don't validate it
* locally; Skatteverket replies 415 if the schema fails parsing, or 400
* with felkod 38 if required fields are missing.
*/
export async function agiValidate(
export async function agiPostUnderlag(
supabase: SupabaseClient,
userId: string,
arbetsgivare: string,
period: string,
payload: SkatteverketAGIInlamning
): Promise<{ ok: boolean; status: number; data?: SkatteverketAGIKontrollresultat; error?: string }> {
xml: string,
): Promise<Result<SkatteverketAGIUnderlagResponse>> {
const response = await skvRequest(
supabase,
userId,
'POST',
`${basePath(arbetsgivare, period)}/kontrollera`,
payload,
{ baseUrl: getAgiApiBaseUrl() }
'/underlag',
xml,
{
baseUrl: getInlamningBaseUrl(),
contentType: 'application/xml',
},
)
if (!response.ok) {
const text = await response.text()
return { ok: false, status: response.status, error: text }
const { error, body } = await readErrorBody(response)
return { ok: false, status: response.status, error, body }
}
const data = await response.json()
const data = (await response.json()) as SkatteverketAGIUnderlagResponse
return { ok: true, status: response.status, data }
}
/**
* Save AGI as draft to Skatteverket's "Eget utrymme".
* Returns kontrollresultat and inlämningsId.
* GET /underlag/{inlamningId}/kontrollresultat.
*
* Returns immediately with the current status. Callers should poll while
* status === 'PROCESSING' (Skatteverket's typical processing time is sub-
* second but the spec doesn't guarantee it).
*/
export async function agiSaveDraft(
export async function agiGetKontrollresultat(
supabase: SupabaseClient,
userId: string,
arbetsgivare: string,
period: string,
payload: SkatteverketAGIInlamning
): Promise<{ ok: boolean; status: number; data?: { inlamningId?: string; kontrollresultat?: SkatteverketAGIKontrollresultat }; error?: string }> {
inlamningId: number,
): Promise<Result<SkatteverketAGIKontrollresultat>> {
const response = await skvRequest(
supabase,
userId,
'GET',
`/underlag/${inlamningId}/kontrollresultat`,
undefined,
{ baseUrl: getInlamningBaseUrl() },
)
if (!response.ok) {
const { error, body } = await readErrorBody(response)
return { ok: false, status: response.status, error, body }
}
const data = (await response.json()) as SkatteverketAGIKontrollresultat
return { ok: true, status: response.status, data }
}
/**
* POST /underlag/{inlamningId}/spara — commit the underlag to Eget utrymme.
*
* Allowed even when kontrollresultat reports DONE_REJECTED — SKV will keep
* the rejected underlag in Eget utrymme as a record. Mina Sidor does NOT
* expose in-place editing of saved underlag; correcting a rejected AGI
* means generating new XML (with the same FK570 specifikationsnummer per
* employee) and resubmitting it as a rättelse via the same /underlag flow.
* The current AGIPanel doesn't auto-spara on rejection; it surfaces the
* findings and leaves recovery (re-generate + re-submit) to the user.
*
* Returns 400 felkod 20 if the underlag was already saved or had no
* errors to fix.
*/
export async function agiSparaUnderlag(
supabase: SupabaseClient,
userId: string,
inlamningId: number,
): Promise<Result<unknown>> {
const response = await skvRequest(
supabase,
userId,
'POST',
`${basePath(arbetsgivare, period)}/inlamningar`,
payload,
{ baseUrl: getAgiApiBaseUrl() }
)
if (!response.ok) {
const text = await response.text()
return { ok: false, status: response.status, error: text }
}
const data = await response.json()
return { ok: true, status: response.status, data }
}
/**
* Get a specific AGI submission.
*/
export async function agiGetSubmission(
supabase: SupabaseClient,
userId: string,
arbetsgivare: string,
period: string,
inlamningId: string
): Promise<{ ok: boolean; status: number; data?: unknown; error?: string }> {
const response = await skvRequest(
supabase,
userId,
'GET',
`${basePath(arbetsgivare, period)}/inlamningar/${inlamningId}`,
`/underlag/${inlamningId}/spara`,
undefined,
{ baseUrl: getAgiApiBaseUrl() }
{ baseUrl: getInlamningBaseUrl() },
)
if (response.status === 404) {
return { ok: true, status: 404, data: null }
}
if (!response.ok) {
const text = await response.text()
return { ok: false, status: response.status, error: text }
const { error, body } = await readErrorBody(response)
return { ok: false, status: response.status, error, body }
}
const data = await response.json()
const data = await response.json().catch(() => ({}))
return { ok: true, status: response.status, data }
}
/**
* Delete a draft AGI submission.
* DELETE /underlag/{inlamningId} — avbryt en inlämning som ännu inte sparats.
*
* Use this to discard an underlag whose kontrollresultat showed errors
* before the user has clicked "spara". For *saved* underlag use
* agiTaBortSparadInlamning() below.
*/
export async function agiDeleteDraft(
export async function agiAvbrytUnderlag(
supabase: SupabaseClient,
userId: string,
arbetsgivare: string,
period: string,
inlamningId: string
): Promise<{ ok: boolean; status: number; error?: string }> {
inlamningId: number,
): Promise<Result<unknown>> {
const response = await skvRequest(
supabase,
userId,
'DELETE',
`${basePath(arbetsgivare, period)}/inlamningar/${inlamningId}`,
`/underlag/${inlamningId}`,
undefined,
{ baseUrl: getAgiApiBaseUrl() }
)
if (response.status !== 204 && !response.ok) {
const text = await response.text()
return { ok: false, status: response.status, error: text }
}
return { ok: true, status: response.status }
}
/**
* Lock the reporting period for signing.
* Returns a signeringslänk for BankID signing on Skatteverket's site.
*/
export async function agiLockPeriod(
supabase: SupabaseClient,
userId: string,
arbetsgivare: string,
period: string
): Promise<{ ok: boolean; status: number; data?: { signeringslank?: string }; error?: string }> {
const response = await skvRequest(
supabase,
userId,
'PUT',
`${basePath(arbetsgivare, period)}/las`,
undefined,
{ baseUrl: getAgiApiBaseUrl() }
{ baseUrl: getInlamningBaseUrl() },
)
if (response.status === 204) return { ok: true, status: 204, data: {} }
if (!response.ok) {
const text = await response.text()
return { ok: false, status: response.status, error: text }
const { error, body } = await readErrorBody(response)
return { ok: false, status: response.status, error, body }
}
const data = await response.json()
const data = await response.json().catch(() => ({}))
return { ok: true, status: response.status, data }
}
/**
* Unlock a locked reporting period (cancel signing).
* DELETE a saved underlag for an arbetsgivare + period. Distinct from
* agiAvbrytUnderlag — this targets the saved copy in Eget utrymme.
*/
export async function agiUnlockPeriod(
export async function agiTaBortSparadInlamning(
supabase: SupabaseClient,
userId: string,
arbetsgivare: string,
period: string
): Promise<{ ok: boolean; status: number; error?: string }> {
period: string,
inlamningId: number,
): Promise<Result<unknown>> {
const response = await skvRequest(
supabase,
userId,
'DELETE',
`${basePath(arbetsgivare, period)}/las`,
`${periodPath(arbetsgivare, period)}/inlamningar/${inlamningId}`,
undefined,
{ baseUrl: getAgiApiBaseUrl() }
{ baseUrl: getInlamningBaseUrl() },
)
if (response.status !== 204 && !response.ok) {
const text = await response.text()
return { ok: false, status: response.status, error: text }
if (response.status === 204) return { ok: true, status: 204, data: {} }
if (!response.ok) {
const { error, body } = await readErrorBody(response)
return { ok: false, status: response.status, error, body }
}
return { ok: true, status: response.status }
return { ok: true, status: response.status, data: {} }
}
/**
* Fetch submitted AGI (after signing).
* Returns kvittensnummer and submission timestamp.
* POST /arbetsgivare/{x}/redovisningsperioder/{y}/skapaGranskningsunderlag.
*
* Returns a Mina Sidor deep-link the user opens in a new tab to sign with
* BankID. `lasPeriod=true` locks the period for changes during signing —
* recommended for the happy path. Caller can later POST .../las or .../lasUpp
* on the hantera API to flip the lock without regenerating the granskning.
*/
export async function agiGetSubmitted(
export async function agiSkapaGranskningsunderlag(
supabase: SupabaseClient,
userId: string,
arbetsgivare: string,
period: string
): Promise<{ ok: boolean; status: number; data?: { kvittensnummer?: string; tidpunkt?: string; signerare?: string } | null; error?: string }> {
period: string,
options: { lasPeriod?: boolean } = {},
): Promise<Result<SkatteverketAGIGranskningsunderlagResponse>> {
const qs = options.lasPeriod ? '?lasPeriod=true' : ''
const response = await skvRequest(
supabase,
userId,
'POST',
`${periodPath(arbetsgivare, period)}/skapaGranskningsunderlag${qs}`,
undefined,
{ baseUrl: getInlamningBaseUrl() },
)
// 409 INCORRECT_DATA returns the same shape as 200 (with a felrapport
// link) — surface it as data rather than an error so the UI can route the
// user to fix the rejected underlag.
if (response.status === 409) {
const data = (await response.json()) as SkatteverketAGIGranskningsunderlagResponse
return { ok: true, status: 409, data }
}
if (!response.ok) {
const { error, body } = await readErrorBody(response)
return { ok: false, status: response.status, error, body }
}
const data = (await response.json()) as SkatteverketAGIGranskningsunderlagResponse
return { ok: true, status: response.status, data }
}
/**
* GET /arbetsgivare/{x}/redovisningsperioder/{y}/kvittenser
* (hanteraredovisningsperiod API).
*
* Returns an empty kvittenser array until the user has signed in Mina Sidor.
* Once signed, each receipt carries uuidKvittens + signeradAv + signeradTid.
*/
export async function agiGetKvittenser(
supabase: SupabaseClient,
userId: string,
arbetsgivare: string,
period: string,
): Promise<Result<SkatteverketAGIKvittenserResponse>> {
const response = await skvRequest(
supabase,
userId,
'GET',
`${basePath(arbetsgivare, period)}/inlamnat`,
`${periodPath(arbetsgivare, period)}/kvittenser`,
undefined,
{ baseUrl: getAgiApiBaseUrl() }
{ baseUrl: getHanteraBaseUrl() },
)
if (response.status === 404) {
return { ok: true, status: 404, data: null }
}
if (!response.ok) {
const text = await response.text()
return { ok: false, status: response.status, error: text }
const { error, body } = await readErrorBody(response)
return { ok: false, status: response.status, error, body }
}
const data = await response.json()
const data = (await response.json()) as SkatteverketAGIKvittenserResponse
return { ok: true, status: response.status, data }
}
/**
* POST /arbetsgivare/{x}/redovisningsperioder/{y}/las (hantera API).
* Locks the period for changes — typically called automatically by
* skapaGranskningsunderlag with lasPeriod=true.
*/
export async function agiLasPeriod(
supabase: SupabaseClient,
userId: string,
arbetsgivare: string,
period: string,
): Promise<Result<unknown>> {
const response = await skvRequest(
supabase,
userId,
'POST',
`${periodPath(arbetsgivare, period)}/las`,
undefined,
{ baseUrl: getHanteraBaseUrl() },
)
if (!response.ok) {
const { error, body } = await readErrorBody(response)
return { ok: false, status: response.status, error, body }
}
const data = await response.json().catch(() => ({}))
return { ok: true, status: response.status, data }
}
/** POST /arbetsgivare/{x}/redovisningsperioder/{y}/lasUpp (hantera API). */
export async function agiLasUppPeriod(
supabase: SupabaseClient,
userId: string,
arbetsgivare: string,
period: string,
): Promise<Result<unknown>> {
const response = await skvRequest(
supabase,
userId,
'POST',
`${periodPath(arbetsgivare, period)}/lasUpp`,
undefined,
{ baseUrl: getHanteraBaseUrl() },
)
if (!response.ok) {
const { error, body } = await readErrorBody(response)
return { ok: false, status: response.status, error, body }
}
const data = await response.json().catch(() => ({}))
return { ok: true, status: response.status, data }
}
@@ -1,93 +0,0 @@
import type { AGIEmployeeData, AGITotals } from '@/lib/salary/agi/xml-generator'
import type { SkatteverketAGIInlamning, SkatteverketHuvuduppgift, SkatteverketIndividuppgift } from '../types'
import { decryptPersonnummer } from '@/lib/salary/personnummer'
// Re-export shared formatting utilities
export { formatRedovisare, formatRedovisningsperiod } from '@/lib/skatteverket/format'
/**
* Convert gnubok salary run data to Skatteverket AGI JSON payload.
*
* JSON property names are derived from Skatteverket's XML element names,
* following the same camelCase convention as the Momsdeklaration API.
* The exact names should be verified against the RAML spec on Utvecklarportalen.
*
* CRITICAL: FK570 (specifikationsnummer) must stay consistent per employee.
* Using a different number creates a new record instead of a correction.
*/
export function buildAGIPayload(
employees: AGIEmployeeData[],
totals: AGITotals,
isCorrection: boolean = false
): SkatteverketAGIInlamning {
const huvuduppgift = buildHuvuduppgift(totals)
const individuppgifter = employees.map(emp => buildIndividuppgift(emp))
return {
rattelse: isCorrection,
huvuduppgift,
individuppgifter,
}
}
function buildHuvuduppgift(totals: AGITotals): SkatteverketHuvuduppgift {
const result: SkatteverketHuvuduppgift = {}
if (totals.totalTax > 0) {
result.avdragenSkatt = Math.round(totals.totalTax)
}
if (totals.totalAvgifterBasis > 0) {
result.summaArbetsgivaravgifterUnderlag = Math.round(totals.totalAvgifterBasis)
}
// Avgifter by category (rutor 060-062)
if (totals.avgifterByCategory.standard) {
result.avgifterUnderlagStandard = Math.round(totals.avgifterByCategory.standard.basis)
}
if (totals.avgifterByCategory.reduced65plus) {
result.avgifterUnderlagAlderspension = Math.round(totals.avgifterByCategory.reduced65plus.basis)
}
if (totals.avgifterByCategory.youth) {
result.avgifterUnderlagUngdom = Math.round(totals.avgifterByCategory.youth.basis)
}
return result
}
function buildIndividuppgift(emp: AGIEmployeeData): SkatteverketIndividuppgift {
// Decrypt personnummer — must be plaintext for Skatteverket
let personnummer: string
try {
personnummer = decryptPersonnummer(emp.personnummer)
} catch {
throw new Error(
`Kunde inte dekryptera personnummer för anställd med FK570=${emp.specificationNumber}. ` +
'AGI kan inte skickas utan giltigt personnummer.'
)
}
const result: SkatteverketIndividuppgift = {
personnummer,
specifikationsnummer: emp.specificationNumber,
}
// Only include non-zero values (Skatteverket treats absent fields as 0)
if (emp.grossSalary > 0) result.kontantBruttoloen = Math.round(emp.grossSalary)
if (emp.taxWithheld > 0) result.avdragenSkatt = Math.round(emp.taxWithheld)
if (emp.avgifterBasis > 0) result.underlagArbetsgivaravgifter = Math.round(emp.avgifterBasis)
if (emp.fSkattPayment && emp.fSkattPayment > 0) result.ersattningFSkatt = Math.round(emp.fSkattPayment)
// Benefits (rutor 012-019)
if (emp.benefitCar && emp.benefitCar > 0) result.formanBil = Math.round(emp.benefitCar)
if (emp.benefitFuel && emp.benefitFuel > 0) result.formanDrivmedel = Math.round(emp.benefitFuel)
if (emp.benefitHousing && emp.benefitHousing > 0) result.formanBostad = Math.round(emp.benefitHousing)
if (emp.benefitMeals && emp.benefitMeals > 0) result.formanKost = Math.round(emp.benefitMeals)
if (emp.benefitOther && emp.benefitOther > 0) result.formanOvrigt = Math.round(emp.benefitOther)
// Absence fields (from 2025)
if (emp.sickDays && emp.sickDays > 0) result.sjukfranvaroDagar = Math.round(emp.sickDays)
if (emp.vabDays && emp.vabDays > 0) result.vabDagar = Math.round(emp.vabDays)
if (emp.parentalDays && emp.parentalDays > 0) result.foraldraledigDagar = Math.round(emp.parentalDays)
return result
}
@@ -144,7 +144,7 @@ export async function skvRequest(
method: string,
path: string,
body?: unknown,
options?: { baseUrl?: string }
options?: { baseUrl?: string; contentType?: string }
): Promise<Response> {
const accessToken = await getValidToken(supabase, userId)
@@ -158,14 +158,20 @@ export async function skvRequest(
'skv_client_correlation_id': crypto.randomUUID(),
}
// contentType defaults to application/json, which is right for moms +
// skattekonto. AGI's POST /underlag takes application/xml — callers pass
// the XML as a string body and override contentType.
let serializedBody: string | undefined
if (body !== undefined) {
headers['Content-Type'] = 'application/json'
const contentType = options?.contentType ?? 'application/json'
headers['Content-Type'] = contentType
serializedBody = typeof body === 'string' ? body : JSON.stringify(body)
}
const response = await fetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
body: serializedBody,
})
// Handle Skatteverket-specific auth/throttle errors uniformly so callers
@@ -179,6 +185,22 @@ export async function skvRequest(
if (response.status === 403) {
const text = await response.text()
// Missing scope on the access token — fires when an existing connection
// pre-dates an extension that needed a new scope (the AGI/`agd` rollout
// is the canonical example). The user has to disconnect + reconnect to
// re-issue a token with the broader scope set; we want to say so
// explicitly instead of letting it surface as a generic 403.
// Body shape per SKV's AGI service description (Tjänstebeskrivning v1.7
// §4.1.2.2): { "error": "invalid_scope", "description": "The required
// scope agd has been requested for that access token." }
if (text.includes('invalid_scope') || text.includes('required scope')) {
throw new SkatteverketAuthError(
'Anslutningen mot Skatteverket saknar nödvändig behörighet för denna ' +
'tjänst. Koppla bort och anslut igen via Inställningar → Skatteverket ' +
'för att förnya tokenen med rätt scope.',
'MISSING_SCOPE'
)
}
// Behörighet saknas — user is authenticated but not authorized for this company
if (text.includes('Behörighet') || text.includes('behörighet')) {
throw new SkatteverketAuthError(
@@ -218,6 +240,9 @@ export async function skvRequest(
* REFRESH_EXHAUSTED — refresh count hit cap (10) before user re-auth
* BEHORIGHET_SAKNAS — 403 with "Behörighet" body; user not authorized
* for this company at SKV (firmatecknare / ombud)
* MISSING_SCOPE — 403 with "invalid_scope" body; the stored token
* was issued before the required scope existed.
* User must disconnect + reconnect.
* ACCESS_DENIED — generic 403
* RATE_LIMITED — 429 from SKV API gateway
* TOKEN_CORRUPTED — stored tokens cannot be decrypted (key rotated
@@ -231,6 +256,7 @@ export class SkatteverketAuthError extends Error {
| 'SESSION_EXPIRED'
| 'REFRESH_EXHAUSTED'
| 'BEHORIGHET_SAKNAS'
| 'MISSING_SCOPE'
| 'ACCESS_DENIED'
| 'RATE_LIMITED'
| 'TOKEN_CORRUPTED'
+6 -1
View File
@@ -17,7 +17,12 @@ import {
*/
const DEFAULT_OAUTH_BASE_URL = 'https://peroauth2.test.skatteverket.se/oauth2/v1/per'
const DEFAULT_SCOPES = 'momsdeklaration inkforetag ska skahmst skattekonto'
// `agd` is the AGI (arbetsgivardeklaration) scope. Source: SKV's service
// description PDF, Tjänstebeskrivning Arbetsgivardeklaration inlämning v1.7,
// section 4.1.2.2 — the 403 "Felaktigt access scope" example shows
// `"description": "The required scope agd has been requested for that access token."`
// The other tokens match the path segments of their respective APIs.
const DEFAULT_SCOPES = 'momsdeklaration inkforetag ska skahmst skattekonto agd'
function getOAuthBaseUrl(): string {
return process.env.SKATTEVERKET_OAUTH_BASE_URL || DEFAULT_OAUTH_BASE_URL
@@ -13,7 +13,10 @@
],
"optionalEnvVars": [
"SKATTEVERKET_OAUTH_BASE_URL",
"SKATTEVERKET_API_BASE_URL"
"SKATTEVERKET_API_BASE_URL",
"SKATTEVERKET_AGD_INLAMNING_API_BASE_URL",
"SKATTEVERKET_AGD_PERIOD_API_BASE_URL",
"SKATTEVERKET_SKATTEKONTO_API_BASE_URL"
],
"npmDependencies": [],
"definition": {
+114 -57
View File
@@ -91,69 +91,126 @@ export type DeclarationStatus =
| 'decided'
// ── AGI (Arbetsgivardeklaration) types ──────────────────────────
//
// Field shapes mirror the Skatteverket RAMLs in dev_docs/:
// • arbetsgivardeklaration-inlamning(1.7.7) (XML ingest + JSON status)
// • arbetsgivardeklaration-hantera-redovisningsperiod(1.2.8) (period management)
//
// AGI submission is XML, not JSON. The XML body posted to /underlag is built
// by lib/salary/agi/xml-generator.ts and stored in agi_declarations.xml_content.
// The types below describe only the JSON responses the extension reads back.
/**
* AGI submission payload — sent to Skatteverket inlämning API.
*
* JSON property names follow the same camelCase convention as the
* Momsdeklaration API. Derived from Skatteverket's XML element names
* and FK field codes. Verify against the RAML spec on Utvecklarportalen.
* Response from POST /underlag — Skatteverket assigns an inlämningsId we
* then use to poll kontrollresultat and to spara/avbryta.
*/
export interface SkatteverketAGIInlamning {
rattelse: boolean
huvuduppgift: SkatteverketHuvuduppgift
individuppgifter: SkatteverketIndividuppgift[]
export interface SkatteverketAGIUnderlagResponse {
inlamningId: number
}
/** Employer-level totals (Huvuduppgift) */
export interface SkatteverketHuvuduppgift {
/** Ruta 001: Total avdragen skatt */
avdragenSkatt?: number
/** Ruta 020: Total underlag arbetsgivaravgifter */
summaArbetsgivaravgifterUnderlag?: number
/** Ruta 060: Avgifter — standard rate (31.42%) */
avgifterUnderlagStandard?: number
/** Ruta 061: Avgifter — ålderspension only (10.21%, 67+ from 2026) */
avgifterUnderlagAlderspension?: number
/** Ruta 062: Avgifter — youth rate (20.81%, ages 19-23, Apr 2026–Sep 2027) */
avgifterUnderlagUngdom?: number
}
/** Per-employee data (Individuppgift) */
export interface SkatteverketIndividuppgift {
/** FK215: Personnummer/samordningsnummer (12 digits, plaintext) */
personnummer: string
/** FK570: Specifikationsnummer — MUST stay consistent per employee */
specifikationsnummer: number
/** Ruta 011: Kontant bruttolön */
kontantBruttoloen?: number
/** Ruta 001: Avdragen skatt */
avdragenSkatt?: number
/** Ruta 012: Förmån bil */
formanBil?: number
/** Ruta 013: Förmån drivmedel */
formanDrivmedel?: number
/** Ruta 014: Förmån bostad */
formanBostad?: number
/** Ruta 015: Förmån kost */
formanKost?: number
/** Ruta 019: Förmån övrigt */
formanOvrigt?: number
/** Ruta 020: Underlag arbetsgivaravgifter */
underlagArbetsgivaravgifter?: number
/** Ruta 131: Ersättning till F-skatt holder */
ersattningFSkatt?: number
/** FK821: Sjukfrånvaro dagar */
sjukfranvaroDagar?: number
/** FK822: VAB dagar */
vabDagar?: number
/** FK823: Föräldraledighet dagar */
foraldraledigDagar?: number
}
/** AGI validation result from Skatteverket /kontrollera */
/**
* Response from GET /underlag/{inlamningId}/kontrollresultat.
* Status flow: PROCESSING → DONE_SUCCESS | DONE_FAILED | DONE_REJECTED.
*
* DONE_SUCCESS — XML accepted, no stop-errors. Caller may proceed to spara.
* DONE_REJECTED — stoppande fel; caller can spara to keep it in Eget utrymme
* for the user to fix in Mina Sidor, or DELETE /underlag/{id}.
* DONE_FAILED — system failure; nothing was saved.
*/
export interface SkatteverketAGIKontrollresultat {
kontroller?: SkatteverketKontroll[]
status: 'PROCESSING' | 'DONE_SUCCESS' | 'DONE_FAILED' | 'DONE_REJECTED'
inlamnad: string // ISO timestamp
antalUppgifter: number
kontrolleradeUppgifter: number
kontrollrapport?: SkatteverketAGIKontrollrapport
}
export interface SkatteverketAGIKontrollrapport {
filstorlek: number
filnamn: string
inkanal: 'MASK' | 'eFIL' | 'eMAN'
status: 'OK' | 'AKTUALITET_OK' | 'WARNING' | 'FAILED'
totaltAntalHU?: number
totaltAntalIU?: number
antalFel?: number
antalVarningar?: number
bearbetningsfel: SkatteverketAGIFel[]
valideringsfel: SkatteverketAGIFel[]
redovisningsperioder: SkatteverketAGIPeriodFel[]
}
export interface SkatteverketAGIFel {
felmeddelande: string
mid?: string
arbetsgivare?: string
}
export interface SkatteverketAGIPeriodFel {
arbetsgivare: string
perioder: Array<{
period: string // YYYYMM
antalIU: number
antalHU: number
antalFel: number
antalVarningar: number
kontrollfel: SkatteverketAGIKontrollfel[]
}>
}
export interface SkatteverketAGIKontrollfel {
felkategori: string
uppgiftsTyp: string // 'HU' | 'IU' | 'FU'
textNyckel: string
textTyp: string
identifierare?: string // pnr/orgnr the rule fired on
specifikationsnummer?: number
felmeddelande: string
felstatus: 'STOPP' | 'ARENDE'
}
/**
* Response from POST /arbetsgivare/{x}/redovisningsperioder/{y}/skapaGranskningsunderlag.
* `link` is a Mina Sidor deep-link the user opens to sign with BankID.
*/
export interface SkatteverketAGIGranskningsunderlagResponse {
link: string
tillstand: 'LOCKED_FOR_SIGNING' | 'UNLOCKED' | 'INCORRECT_DATA' | 'RECEIVING' | 'CALCULATING'
meddelande: string
}
/**
* Response from GET /arbetsgivare/{x}/redovisningsperioder/{y}/kvittenser.
* Empty array until the user has signed in Mina Sidor.
*/
export interface SkatteverketAGIKvittenserResponse {
kvittenser: SkatteverketAGIKvittens[]
}
export interface SkatteverketAGIKvittens {
arbetsgivare: string // SSÅÅMMDDNNNK
period: string // YYYYMM
uuidKvittens?: string
signeradAv?: string
signeradTid?: string // ISO timestamp
underlag: {
arbetsgivarregistrerad: string
redovisningsperiod: string
antalIu: number
antalIuTillagda: number
antalIuBorttagna: number
omprovningSanktSkatteavdrIU?: string
errorMessage?: string
}
}
/**
* Standard error envelope for both AGI APIs (HTTP 400/404/409/etc).
* Distinct from the moms felkod envelope.
*/
export interface SkatteverketAGIErrorBody {
kod: number
meddelandeTillAnvandare: string
meddelandeTillUtvecklare?: string
}
export interface SkatteverketSubmission {
@@ -0,0 +1,37 @@
-- Add 'pending_signature' to agi_declarations.status enum.
--
-- Background: the new AGI flow (POST /underlag → kontrollresultat → spara →
-- skapaGranskningsunderlag → kvittenser) needs a status that captures
-- "underlag has been saved into Skatteverket's Eget utrymme but the user
-- has not yet completed BankID signing in Mina Sidor." Reusing 'exported'
-- for that interval misstates the filing outcome — Eget utrymme is a
-- staging area, not a filing — and that misrepresentation conflicts with
-- the behandlingshistorik faithfulness requirement in BFNAR 2013:2 kap 8
-- and BFL 5 kap 5§.
--
-- Lifecycle after this migration:
-- generated — XML built but nothing has been sent to Skatteverket
-- pending_signature — underlag accepted into Eget utrymme; awaiting BankID
-- submitted — kvittens received; AGI is filed
-- exported — preserved for the legacy manual XML download path
-- accepted — reserved (Skatteverket does not currently expose this)
-- rejected — reserved (kontrollresultat DONE_REJECTED could land here)
--
-- Migrations are append-only — we drop and recreate the CHECK constraint
-- with the new value rather than mutating the existing one in place.
ALTER TABLE public.agi_declarations
DROP CONSTRAINT IF EXISTS agi_declarations_status_check;
ALTER TABLE public.agi_declarations
ADD CONSTRAINT agi_declarations_status_check
CHECK (status IN (
'generated',
'pending_signature',
'exported',
'submitted',
'accepted',
'rejected'
));
NOTIFY pgrst, 'reload schema';
+9 -1
View File
@@ -1363,6 +1363,8 @@ export interface OnboardingProgress {
hasInvoices: boolean
hasBankConnected: boolean
hasSIEImport: boolean
/** True when the active user has a stored Skatteverket OAuth token. */
hasSkatteverketConnected: boolean
}
// Onboarding step data
@@ -2470,7 +2472,13 @@ export type SalaryType = 'monthly' | 'hourly'
export type FSkattStatus = 'a_skatt' | 'f_skatt' | 'fa_skatt' | 'not_verified'
export type VacationRule = 'procentregeln' | 'sammaloneregeln'
export type SalaryRunStatus = 'draft' | 'review' | 'approved' | 'paid' | 'booked' | 'corrected'
export type AGIStatus = 'generated' | 'exported' | 'submitted' | 'accepted' | 'rejected'
export type AGIStatus =
| 'generated' // XML built from a salary run; nothing sent to SKV yet
| 'pending_signature' // underlag accepted into Eget utrymme; awaiting BankID
| 'exported' // legacy: manual XML download path
| 'submitted' // kvittens received; AGI is filed
| 'accepted' // reserved (SKV does not currently expose this)
| 'rejected' // reserved (kontrollresultat DONE_REJECTED could land here)
export type SalaryLineItemType =
| 'monthly_salary' | 'hourly_salary' | 'overtime' | 'bonus' | 'commission'
+4
View File
@@ -39,6 +39,10 @@
{
"path": "/api/extensions/skatteverket/skattekonto/sync/cron",
"schedule": "0 4 * * *"
},
{
"path": "/api/extensions/skatteverket/agi/kvittenser/cron",
"schedule": "0 */2 * * *"
}
]
}