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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user