* 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>
111 lines
4.8 KiB
TypeScript
111 lines
4.8 KiB
TypeScript
/**
|
|
* Tests for the shared declaration-prep functions. These are the single
|
|
* source of truth for what gets filed to Skatteverket — the HTTP route
|
|
* handlers and the commit-side services both go through them, so a regression
|
|
* here would mean different numbers filed than the user reviewed (no-drift
|
|
* compliance guarantee).
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { createQueuedMockSupabase } from '@/tests/helpers'
|
|
import type { VatDeclarationRutor } from '@/types'
|
|
|
|
const mockCalculateVatDeclaration = vi.fn()
|
|
vi.mock('@/lib/reports/vat-declaration', () => ({
|
|
calculateVatDeclaration: (...a: unknown[]) => mockCalculateVatDeclaration(...a),
|
|
}))
|
|
|
|
import { buildMomsuppgift, buildAgiUnderlag, resolveRedovisare } from '../lib/declaration-prep'
|
|
import { rutorToMomsuppgift } from '../lib/mappers'
|
|
|
|
const READ_KEYS = [
|
|
'ruta05', 'ruta06', 'ruta07', 'ruta08', 'ruta10', 'ruta11', 'ruta12',
|
|
'ruta20', 'ruta21', 'ruta22', 'ruta23', 'ruta24', 'ruta30', 'ruta31', 'ruta32',
|
|
'ruta35', 'ruta36', 'ruta37', 'ruta38', 'ruta39', 'ruta40', 'ruta41', 'ruta42',
|
|
'ruta48', 'ruta50', 'ruta60', 'ruta61', 'ruta62',
|
|
]
|
|
|
|
function zeroRutor(): VatDeclarationRutor {
|
|
return Object.fromEntries(READ_KEYS.map((k) => [k, 0])) as unknown as VatDeclarationRutor
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('resolveRedovisare', () => {
|
|
it('formats an aktiebolag org number to the 12-digit redovisare', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { org_number: '5560000000', entity_type: 'aktiebolag' } })
|
|
const redovisare = await resolveRedovisare(supabase as never, 'company-1')
|
|
expect(redovisare).toBe('165560000000')
|
|
})
|
|
|
|
it('throws when org number is missing', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { org_number: null, entity_type: 'aktiebolag' } })
|
|
await expect(resolveRedovisare(supabase as never, 'company-1')).rejects.toThrow(/Organisationsnummer saknas/)
|
|
})
|
|
})
|
|
|
|
describe('buildMomsuppgift', () => {
|
|
it('produces the same momsuppgift the route handler would (rutorToMomsuppgift over the GL rutor)', async () => {
|
|
const rutor = zeroRutor()
|
|
rutor.ruta10 = 250 // output VAT 25%
|
|
rutor.ruta48 = 100 // input VAT
|
|
mockCalculateVatDeclaration.mockResolvedValue({ rutor })
|
|
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { org_number: '5560000000', entity_type: 'aktiebolag' } }) // resolveRedovisare
|
|
|
|
const result = await buildMomsuppgift(supabase as never, 'company-1', { periodType: 'monthly', year: 2025, period: 3 })
|
|
|
|
expect(result.redovisare).toBe('165560000000')
|
|
expect(result.redovisningsperiod).toBe('202503')
|
|
// Identical to the direct mapper output — locks the no-drift guarantee.
|
|
expect(result.momsuppgift).toEqual(rutorToMomsuppgift(rutor))
|
|
expect(result.momsuppgift.momsForsaljningUtgaendeHog).toBe(250)
|
|
expect(result.momsuppgift.ingaendeMomsAvdrag).toBe(100)
|
|
expect(result.momsuppgift.summaMoms).toBe(150)
|
|
expect(mockCalculateVatDeclaration).toHaveBeenCalledWith(expect.anything(), 'company-1', 'monthly', 2025, 3)
|
|
})
|
|
})
|
|
|
|
describe('buildAgiUnderlag', () => {
|
|
it('loads the latest XML and formats arbetsgivare + period', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { status: 'booked' } }) // salary_runs status guard
|
|
enqueue({ data: { org_number: '5560000000', entity_type: 'aktiebolag' } }) // resolveRedovisare
|
|
enqueue({ data: { xml_content: '<agi/>', period_year: 2026, period_month: 3 } }) // agi_declarations
|
|
|
|
const result = await buildAgiUnderlag(supabase as never, 'company-1', 'sr-1')
|
|
|
|
expect(result).toMatchObject({
|
|
arbetsgivare: '165560000000',
|
|
period: '202603',
|
|
salaryRunId: 'sr-1',
|
|
xml: '<agi/>',
|
|
periodYear: 2026,
|
|
periodMonth: 3,
|
|
})
|
|
})
|
|
|
|
it('throws when the salary run is not past draft', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { status: 'draft' } })
|
|
await expect(buildAgiUnderlag(supabase as never, 'company-1', 'sr-1')).rejects.toThrow(/efter granskning/)
|
|
})
|
|
|
|
it('throws when salaryRunId is missing', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
await expect(buildAgiUnderlag(supabase as never, 'company-1', '')).rejects.toThrow(/salaryRunId/)
|
|
})
|
|
|
|
it('throws when no AGI XML exists', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { status: 'booked' } })
|
|
enqueue({ data: { org_number: '5560000000', entity_type: 'aktiebolag' } })
|
|
enqueue({ data: { xml_content: null, period_year: 2026, period_month: 3 } })
|
|
await expect(buildAgiUnderlag(supabase as never, 'company-1', 'sr-1')).rejects.toThrow(/AGI-XML saknas/)
|
|
})
|
|
})
|