Files
accounted/extensions/general/skatteverket/lib/declaration-prep.ts
T
Jakob WennbergandClaude Opus 4.8 679b154ad2 feat(skatteverket): MCP wrappers for momsdeklaration + AGI filing (P0-5) (#692)
* feat(skatteverket): MCP wrappers for momsdeklaration + AGI filing (P0-5)

Expose the complete Skatteverket extension as five MCP tools so VAT
(momsdeklaration) and employer (AGI/arbetsgivardeklaration) filing can be
driven from Claude. Commit = "send for BankID signing" (returns a signing
link), never "file" — the user's signature in the browser is the irreversible
act, kept outside the tooling.

Tools (extensions/general/mcp-server/server.ts):
- gnubok_vat_declaration_validate  (compliance:read) — live POST /kontrollera
- gnubok_vat_declaration_submit    (skatteverket:write) — stages submit_vat_declaration
- gnubok_vat_declaration_status    (compliance:read) — GET /inlamnat + /beslutat
- gnubok_agi_submit                (skatteverket:write) — stages submit_agi
- gnubok_agi_status                (compliance:read) — local state + live kvittenser

Architecture:
- Core (lib/pending-operations/commit.ts) cannot import @/extensions (CI guard),
  so the two submit ops dispatch into the extension via the new
  Extension.services channel (first use): registry-resolved
  commitSubmitVatDeclaration / commitSubmitAgi run the SKV chain and return a
  shared SkvSubmitResult (lib/pending-operations/skatteverket-commit.ts).
- Recoverable failures (extension disabled, no connection, rate-limited, still
  processing) release the op back to 'pending' via SkatteverketRecoverableError
  — same contract as AccountsNotInChartError — so the user reconnects and
  re-approves the SAME op. SKV business rejections reject the op.
- No-drift: parseDeclarationRequest / loadAGIXml extracted to
  lib/declaration-prep.ts (buildMomsuppgift / buildAgiUnderlag / resolveRedovisare)
  so route, preview, and commit file identical figures. writeSkatteverketAudit
  hoisted to lib/audit.ts; read tools + executors write BFL audit rows too.
- New scope skatteverket:write (opt-in, in STAGING_SCOPES so SoD ack fires),
  4 structured error codes, sv/en strings, ApiKeysPanel row.
- Migration 20260620120000 adds submit_vat_declaration / submit_agi to the
  pending_operations.operation_type CHECK (must apply to prod post-merge).

Tests: 42 new across executors, MCP tools, declaration-prep, error-map, and the
VAT commit chain. Full suite green (5287), build clean, lint-ratchet at baseline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: add PR-Agent AI review (SHA-pinned, dedicated Bedrock key)

Greptile went silent after #682 (app/account-side, not repo config). Add the
open-source PR-Agent GitHub Action as a replacement, hardened for supply chain:

- Pinned to the v0.36.0 commit SHA (ffe1f89), not the movable tag — the repo
  was recently transferred to a new, unverified org (The-PR-Agent), though it's
  the genuine original pr-agent (repo id 662766482, 11.5k stars).
- Runs on a DEDICATED, minimal IAM key (bedrock:InvokeModel only) via
  PR_AGENT_AWS_* secrets — never the app's general AWS credentials.
- Only /review runs automatically; /describe and /improve are disabled so PR
  descriptions are never overwritten.

Requires three new secrets before it functions: PR_AGENT_AWS_ACCESS_KEY_ID,
PR_AGENT_AWS_SECRET_ACCESS_KEY, PR_AGENT_AWS_REGION (EU region).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci(pr-agent): handle push events + restrict push to /review

PR-Agent skips synchronize (push) events by default, so the bot ran green but
posted nothing. Enable handle_push_trigger and scope push_commands to /review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci(pr-agent): fix pr_actions (event list, not commands) + add synchronize

pr_actions is the list of PR event actions to handle, not slash-commands.
Setting it to ["/review"] removed every real event from the allowlist, so the
bot skipped everything. Restore the default events + synchronize; command
selection stays on the auto_review/describe/improve booleans (review-only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci(pr-agent): raise max_model_tokens to 64k for fuller diff coverage

Default ~32k input window truncated large PRs. Sonnet 4.6 has 200k context.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci(pr-agent): use Claude Opus 4.8 (Sonnet 4.6 fallback)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(skatteverket): scope AGI status flips by salary_run_id

Bot review (swedish-compliance) caught that commitSubmitAgi flipped
agi_declarations status by (company_id, period) only. A correction run sharing
the period would have its still-valid declaration co-flipped to rejected/
pending_signature. Scope both updates by salary_run_id (in scope from params) —
more precise than the period-only route handler, which has no run id.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(pg): fix gen_random_bytes assertion for modern pgcrypto

OpenSSL-backed pgcrypto (CI Postgres image) rejects gen_random_bytes(0) with
'Length not in range' rather than returning empty bytea, so the pre-existing
'returns empty bytea' assertion fails on every pg-real run (repo-wide, not
specific to this PR). Assert the real contract — exactly n bytes for a positive
n — instead of the version-dependent 0-byte edge case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:43:15 +02:00

153 lines
4.9 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import type { VatPeriodType } from '@/types'
import { calculateVatDeclaration } from '@/lib/reports/vat-declaration'
import { rutorToMomsuppgift, formatRedovisare, formatRedovisningsperiod } from './mappers'
import type { SkatteverketMomsuppgift } from '../types'
/**
* Request-free Skatteverket declaration prep.
*
* These functions are the single source of truth for what gets filed to
* Skatteverket. They are shared by the HTTP route handlers
* (parseDeclarationRequest / loadAGIXml) and the commit-side services
* (commitSubmitVatDeclaration / commitSubmitAgi) so the numbers and XML
* computed at preview time match exactly what is filed at commit time.
*
* Compliance-critical: drift between the two paths would mean different
* figures filed to SKV than the user reviewed. Keep these the only place that
* computes momsuppgift / loads AGI XML.
*/
export interface VatDeclarationPrep {
redovisare: string
redovisningsperiod: string
momsuppgift: SkatteverketMomsuppgift
}
export interface AgiUnderlagPrep {
arbetsgivare: string
period: string // YYYYMM
salaryRunId: string
xml: string
periodYear: number
periodMonth: number
}
/**
* Resolve a company's 12-digit "redovisare" string from company_settings.
* Shared by the VAT and AGI paths and by the status tools that only need the
* identifier (no momsuppgift / XML compute).
*/
export async function resolveRedovisare(
supabase: SupabaseClient,
companyId: string,
): Promise<string> {
const { data: settings } = await supabase
.from('company_settings')
.select('org_number, entity_type')
.eq('company_id', companyId)
.single()
if (!settings?.org_number) {
throw new Error('Organisationsnummer saknas i företagsinställningar')
}
return formatRedovisare(settings.org_number, settings.entity_type)
}
/**
* Compute the momsuppgift filed to SKV for a period, from the general ledger.
* Body lifted verbatim from the former parseDeclarationRequest so route and
* commit paths produce identical payloads.
*/
export async function buildMomsuppgift(
supabase: SupabaseClient,
companyId: string,
input: { periodType: VatPeriodType; year: number; period: number },
): Promise<VatDeclarationPrep> {
const { periodType, year, period } = input
const redovisare = await resolveRedovisare(supabase, companyId)
const redovisningsperiod = formatRedovisningsperiod(periodType, year, period)
// Calculate VAT declaration from the general ledger
const declaration = await calculateVatDeclaration(
supabase,
companyId,
periodType,
year,
period,
)
const momsuppgift = rutorToMomsuppgift(declaration.rutor)
return { redovisare, redovisningsperiod, momsuppgift }
}
/**
* 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),
* alongside the formatted arbetsgivare/period strings used downstream by the
* granskningsunderlag and kvittenser calls.
*
* Body lifted verbatim from the former loadAGIXml — including the salary-run
* status guard (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).
*/
export async function buildAgiUnderlag(
supabase: SupabaseClient,
companyId: string,
salaryRunId: string,
): Promise<AgiUnderlagPrep> {
if (!salaryRunId) {
throw new Error('Saknar obligatoriskt fält: salaryRunId')
}
const { data: run, error: runError } = await supabase
.from('salary_runs')
.select('status')
.eq('id', salaryRunId)
.eq('company_id', 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 arbetsgivare = await resolveRedovisare(supabase, companyId)
// 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 supabase
.from('agi_declarations')
.select('xml_content, period_year, period_month')
.eq('company_id', companyId)
.eq('salary_run_id', salaryRunId)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
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 period = formatRedovisningsperiod('monthly', declaration.period_year, declaration.period_month)
return {
arbetsgivare,
period,
salaryRunId,
xml: declaration.xml_content,
periodYear: declaration.period_year,
periodMonth: declaration.period_month,
}
}