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:
co-authored by
Claude Opus 4.7
parent
c03582b5c7
commit
432a8b60dc
@@ -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' }),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -39,6 +39,10 @@
|
||||
{
|
||||
"path": "/api/extensions/skatteverket/skattekonto/sync/cron",
|
||||
"schedule": "0 4 * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/extensions/skatteverket/agi/kvittenser/cron",
|
||||
"schedule": "0 */2 * * *"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user