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>
This commit is contained in:
Jakob Wennberg
2026-06-08 12:43:15 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 122bcbbcc1
commit 679b154ad2
22 changed files with 1913 additions and 165 deletions
+93
View File
@@ -50,6 +50,12 @@ import { parseSIEFile } from '@/lib/import/sie-parser'
import { executeSIEImport, undoSIEImport } from '@/lib/import/sie-import'
import type { AccountMapping } from '@/lib/import/types'
import { AccountsNotInChartError, isBookkeepingError, ACCOUNTS_NOT_IN_CHART } from '@/lib/bookkeeping/errors'
import { extensionRegistry } from '@/lib/extensions/registry'
import {
SkatteverketRecoverableError,
type SkatteverketCommitServices,
type SkvSubmitResult,
} from '@/lib/pending-operations/skatteverket-commit'
import { getEmailService } from '@/lib/email/service'
import {
generateInvoiceEmailHtml,
@@ -2833,6 +2839,71 @@ async function commitGenerateAgi(
}
}
// ── Skatteverket filing commit handlers (PR5) ─────────────────────
//
// Core cannot import @/extensions (CI guard), so these reach the Skatteverket
// extension only through the registry-resolved `services` channel. The service
// runs the SKV chain and returns a SkvSubmitResult (shared shape in
// ./skatteverket-commit). A recoverable failure throws SkatteverketRecoverable-
// Error, which the dispatcher catch releases back to 'pending'; a non-recoverable
// failure becomes a plain { error, status } that rejects the op.
function getSkatteverketServices(): SkatteverketCommitServices {
const services = extensionRegistry.get('skatteverket')?.services as
| Partial<SkatteverketCommitServices>
| undefined
if (!services?.commitSubmitVatDeclaration || !services?.commitSubmitAgi) {
// Extension absent or not wired. Recoverable — leave the op pending so a
// re-enable + re-approve works without re-staging.
throw new SkatteverketRecoverableError(
'Skatteverket-integrationen är inte tillgänglig.',
'EXTENSION_DISABLED',
503,
)
}
return services as SkatteverketCommitServices
}
function handleSkvSubmitResult(result: SkvSubmitResult): ExecutorResult {
if (!result.ok) {
if (result.recoverable) {
throw new SkatteverketRecoverableError(result.error, result.code, result.http_status)
}
return { error: result.error, status: result.http_status }
}
const data: Record<string, unknown> = { ...result, status: 'awaiting_signature' }
delete data.ok
return { data }
}
async function commitSubmitVatDeclaration(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: Record<string, unknown>,
): Promise<ExecutorResult> {
if (!params.period_type || !params.year || !params.period) {
return { error: 'period_type, year och period krävs', status: 400 }
}
const services = getSkatteverketServices()
const result = await services.commitSubmitVatDeclaration(supabase, userId, companyId, params)
return handleSkvSubmitResult(result)
}
async function commitSubmitAgi(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: Record<string, unknown>,
): Promise<ExecutorResult> {
if (!params.salary_run_id) {
return { error: 'salary_run_id krävs', status: 400 }
}
const services = getSkatteverketServices()
const result = await services.commitSubmitAgi(supabase, userId, companyId, params)
return handleSkvSubmitResult(result)
}
// ── Multi-tx commit handlers (PRs #603/#606/#608/#610) ────────────
//
// Both wrap their SQL RPC. The RPCs do all the heavy lifting (locking,
@@ -3194,6 +3265,12 @@ async function commitPendingOperationInner(
case 'link_transaction_journal_entry':
result = await commitLinkTransactionJournalEntry(supabase, userId, companyId, pendingOp.params)
break
case 'submit_vat_declaration':
result = await commitSubmitVatDeclaration(supabase, userId, companyId, pendingOp.params)
break
case 'submit_agi':
result = await commitSubmitAgi(supabase, userId, companyId, pendingOp.params)
break
default:
return {
status: 'failed',
@@ -3220,6 +3297,22 @@ async function commitPendingOperationInner(
account_numbers: err.accountNumbers,
}
}
// Recoverable Skatteverket failure (extension disabled, no connection,
// rate-limited, still processing). Same contract as accounts-not-in-chart:
// release the claim back to 'pending' so the user can fix the connection/
// flag and re-approve the SAME op, and surface the structured code.
if (err instanceof SkatteverketRecoverableError) {
await supabase
.from('pending_operations')
.update({ status: 'pending' })
.eq('id', pendingOp.id)
return {
status: 'failed',
error: err.message,
http_status: err.httpStatus,
code: err.code,
}
}
const isBkErr = isBookkeepingError(err)
const message = err instanceof Error ? err.message : (isBkErr ? 'Bookkeeping error' : 'Executor failed')
// Release the claim by transitioning to 'rejected' so the row never gets