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:
co-authored by
Claude Opus 4.8
parent
122bcbbcc1
commit
679b154ad2
+14
-1
@@ -26,7 +26,8 @@ export const API_KEY_SCOPES = {
|
||||
'operations:read': { label: 'Operationer — läs', description: 'Hämta status för långkörande operationer (importer, bokslut, omvärdering)' },
|
||||
'documents:read': { label: 'Dokument — läs', description: 'Lista och hämta dokumentbilagor' },
|
||||
'documents:write': { label: 'Dokument — skriv', description: 'Ladda upp och koppla dokument till verifikationer' },
|
||||
'compliance:read': { label: 'Compliance — läs', description: 'Pre-flight-kontroller: momsstängning, bokslutsberedskap, voucher-gap, IB/UB-kontinuitet' },
|
||||
'compliance:read': { label: 'Compliance — läs', description: 'Pre-flight-kontroller: momsstängning, bokslutsberedskap, voucher-gap, IB/UB-kontinuitet; Skatteverket-status (moms + AGI)' },
|
||||
'skatteverket:write': { label: 'Skatteverket — skriv', description: 'Lämna momsdeklaration och arbetsgivardeklaration (AGI) till Skatteverket (stagas; signeras med BankID)' },
|
||||
'agent:read': { label: 'Agent — läs', description: 'Specialiserad bokföringsassistent: profil, laddade specialister/atomer, minnen (briefing + skill-katalog)' },
|
||||
'agent:write': { label: 'Agent — skriv', description: 'Spara och ta bort agentens minnen om företaget (remember_fact, forget_fact)' },
|
||||
'pending_operations:read': { label: 'Stagade operationer — läs', description: 'Lista pending_operations (staged writes awaiting approval)' },
|
||||
@@ -113,6 +114,10 @@ export const STAGING_SCOPES: ApiKeyScope[] = [
|
||||
'bookkeeping:write',
|
||||
'payroll:write',
|
||||
'documents:write',
|
||||
// Skatteverket submit tools stage submit_vat_declaration / submit_agi, so a
|
||||
// key holding both this and pending_operations:approve is a SoD conflict —
|
||||
// findStageApproveConflict picks it up automatically from this list.
|
||||
'skatteverket:write',
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -142,6 +147,7 @@ export const SCOPE_GROUPS = [
|
||||
{ domain: 'payroll', label: 'Löner', read: 'payroll:read' as const, write: 'payroll:write' as const },
|
||||
{ domain: 'pending_operations', label: 'Stagade operationer', read: 'pending_operations:read' as const, write: 'pending_operations:approve' as const },
|
||||
{ domain: 'agent', label: 'Agent', read: 'agent:read' as const, write: 'agent:write' as const },
|
||||
{ domain: 'skatteverket', label: 'Skatteverket', read: null, write: 'skatteverket:write' as const },
|
||||
] as const
|
||||
|
||||
/** Map MCP tool name → required scope. Tools omitted from this map are available to any authenticated key (e.g. discovery/search/skill loading). */
|
||||
@@ -247,6 +253,13 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
|
||||
gnubok_list_pending_operations: 'pending_operations:read',
|
||||
gnubok_approve_pending_operation: 'pending_operations:approve',
|
||||
gnubok_reject_pending_operation: 'pending_operations:approve',
|
||||
// Skatteverket filing (PR5). Reads are compliance:read (status of moms/AGI);
|
||||
// the two submit tools require the opt-in skatteverket:write staging scope.
|
||||
gnubok_vat_declaration_validate: 'compliance:read',
|
||||
gnubok_vat_declaration_status: 'compliance:read',
|
||||
gnubok_agi_status: 'compliance:read',
|
||||
gnubok_vat_declaration_submit: 'skatteverket:write',
|
||||
gnubok_agi_submit: 'skatteverket:write',
|
||||
}
|
||||
|
||||
export function validateScopes(scopes: unknown): ApiKeyScope[] | null {
|
||||
|
||||
@@ -2199,6 +2199,45 @@ const BULK_BOOK: Record<string, StructuredErrorEntry> = {
|
||||
},
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Skatteverket filing codes (PR5 — MCP momsdeklaration + AGI tools)
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const SKATTEVERKET: Record<string, StructuredErrorEntry> = {
|
||||
EXTENSION_DISABLED: {
|
||||
httpStatus: 503,
|
||||
message_sv: 'Skatteverket-integrationen är inte aktiverad i denna miljö.',
|
||||
message_en: 'The Skatteverket integration is not enabled in this environment.',
|
||||
},
|
||||
SKATTEVERKET_NOT_CONNECTED: {
|
||||
httpStatus: 401,
|
||||
message_sv:
|
||||
'Anslutningen till Skatteverket saknas eller har gått ut. Anslut med BankID under Inställningar → Skatteverket.',
|
||||
message_en: 'No valid Skatteverket connection. Reconnect with BankID before retrying.',
|
||||
remediation: {
|
||||
description:
|
||||
'Connect (or reconnect) to Skatteverket with BankID under Settings → Skatteverket, then retry.',
|
||||
},
|
||||
},
|
||||
SKATTEVERKET_ACCESS_DENIED: {
|
||||
httpStatus: 403,
|
||||
message_sv:
|
||||
'Behörighet saknas hos Skatteverket för det här företaget. Kontrollera att du är firmatecknare eller deklarationsombud.',
|
||||
message_en:
|
||||
'Skatteverket denied access for this company (missing authorisation or scope).',
|
||||
remediation: {
|
||||
description:
|
||||
'Verify the signed-in user is firmatecknare/deklarationsombud for this company at Skatteverket, then reconnect with BankID.',
|
||||
},
|
||||
},
|
||||
SKATTEVERKET_RATE_LIMITED: {
|
||||
httpStatus: 429,
|
||||
message_sv: 'För många förfrågningar mot Skatteverket. Vänta en stund och försök igen.',
|
||||
message_en: 'Skatteverket rate limit exceeded.',
|
||||
retryable: true,
|
||||
},
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Combined registry
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
@@ -2238,6 +2277,7 @@ const REGISTRY: Record<string, StructuredErrorEntry> = {
|
||||
...COMPANY,
|
||||
...API_KEY,
|
||||
...PROVIDER,
|
||||
...SKATTEVERKET,
|
||||
}
|
||||
|
||||
export function getErrorEntry(code: string): StructuredErrorEntry | undefined {
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Unit tests for commitSubmitVatDeclaration / commitSubmitAgi.
|
||||
* Driven through the public commitPendingOperation dispatcher.
|
||||
*
|
||||
* The MCP submit tools stage submit_vat_declaration / submit_agi ops; this
|
||||
* dispatcher resolves the skatteverket extension's commit services via the
|
||||
* registry and translates their SkvSubmitResult into the op lifecycle:
|
||||
* - ok → committed (signing_url in result_data)
|
||||
* - recoverable failure → released back to 'pending' (re-approve works)
|
||||
* - non-recoverable / SKV business error → rejected
|
||||
*
|
||||
* A FAKE extension is registered in the registry so no real SKV/extension
|
||||
* code runs — this isolates the core wiring (registry resolution + lifecycle).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { extensionRegistry } from '@/lib/extensions/registry'
|
||||
import type { Extension } from '@/lib/extensions/types'
|
||||
import type { SkvSubmitResult } from '@/lib/pending-operations/skatteverket-commit'
|
||||
import type { PendingOperation } from '@/types'
|
||||
import { commitPendingOperation } from '../commit'
|
||||
|
||||
function makePendingOp(overrides: Partial<PendingOperation>): PendingOperation {
|
||||
return {
|
||||
id: 'op-1',
|
||||
user_id: 'user-1',
|
||||
company_id: 'company-1',
|
||||
operation_type: 'submit_vat_declaration',
|
||||
status: 'pending',
|
||||
title: 'test',
|
||||
params: {},
|
||||
preview_data: {},
|
||||
result_data: null,
|
||||
actor_type: 'user',
|
||||
actor_id: null,
|
||||
actor_label: null,
|
||||
risk_level: 'high',
|
||||
created_at: '2026-06-01T00:00:00Z',
|
||||
resolved_at: null,
|
||||
updated_at: '2026-06-01T00:00:00Z',
|
||||
...overrides,
|
||||
} as PendingOperation
|
||||
}
|
||||
|
||||
function registerFakeSkatteverket(
|
||||
services: Record<string, (...a: unknown[]) => Promise<SkvSubmitResult>>,
|
||||
): void {
|
||||
extensionRegistry.register({
|
||||
id: 'skatteverket',
|
||||
name: 'fake-skatteverket',
|
||||
version: '0.0.0',
|
||||
services,
|
||||
} as unknown as Extension)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
})
|
||||
afterEach(() => {
|
||||
extensionRegistry.clear()
|
||||
})
|
||||
|
||||
describe('commitPendingOperation: submit_vat_declaration / submit_agi', () => {
|
||||
it('happy VAT path → committed with signing_url + awaiting_signature status', async () => {
|
||||
const vat = vi.fn().mockResolvedValue({
|
||||
ok: true, signing_url: 'https://skv.test/sign/abc', redovisningsperiod: '202503',
|
||||
})
|
||||
registerFakeSkatteverket({ commitSubmitVatDeclaration: vat, commitSubmitAgi: vi.fn() })
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: null, error: null }) // dispatcher commit update
|
||||
|
||||
const op = makePendingOp({ params: { period_type: 'monthly', year: 2025, period: 3 } })
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('committed')
|
||||
expect(result.data).toMatchObject({ signing_url: 'https://skv.test/sign/abc', status: 'awaiting_signature' })
|
||||
expect(vat).toHaveBeenCalledWith(expect.anything(), 'user-1', 'company-1', {
|
||||
period_type: 'monthly', year: 2025, period: 3,
|
||||
})
|
||||
})
|
||||
|
||||
it('happy AGI path → committed with signing_url', async () => {
|
||||
const agi = vi.fn().mockResolvedValue({ ok: true, signing_url: 'https://skv.test/agi/xyz', period: '202503' })
|
||||
registerFakeSkatteverket({ commitSubmitVatDeclaration: vi.fn(), commitSubmitAgi: agi })
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const op = makePendingOp({ operation_type: 'submit_agi', params: { salary_run_id: 'sr-1' } })
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('committed')
|
||||
expect(result.data).toMatchObject({ signing_url: 'https://skv.test/agi/xyz' })
|
||||
expect(agi).toHaveBeenCalledWith(expect.anything(), 'user-1', 'company-1', { salary_run_id: 'sr-1' })
|
||||
})
|
||||
|
||||
it('no service registered → failed EXTENSION_DISABLED, op released to pending', async () => {
|
||||
// registry is empty (afterEach cleared it; nothing registered here)
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: null, error: null }) // release-to-pending update
|
||||
|
||||
const op = makePendingOp({ params: { period_type: 'monthly', year: 2025, period: 3 } })
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.code).toBe('EXTENSION_DISABLED')
|
||||
expect(result.http_status).toBe(503)
|
||||
})
|
||||
|
||||
it('recoverable service result → released to pending with the structured code', async () => {
|
||||
const vat = vi.fn().mockResolvedValue({
|
||||
ok: false, code: 'SKATTEVERKET_NOT_CONNECTED', http_status: 401, recoverable: true, error: 'no connection',
|
||||
})
|
||||
registerFakeSkatteverket({ commitSubmitVatDeclaration: vat, commitSubmitAgi: vi.fn() })
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null })
|
||||
enqueue({ data: null, error: null }) // release-to-pending update
|
||||
|
||||
const op = makePendingOp({ params: { period_type: 'monthly', year: 2025, period: 3 } })
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.code).toBe('SKATTEVERKET_NOT_CONNECTED')
|
||||
expect(result.http_status).toBe(401)
|
||||
})
|
||||
|
||||
it('non-recoverable service result → op rejected (consumed)', async () => {
|
||||
const vat = vi.fn().mockResolvedValue({
|
||||
ok: false, code: 'SKATTEVERKET_SUBMIT_REJECTED', http_status: 400, recoverable: false, error: 'SKV rejected the draft',
|
||||
})
|
||||
registerFakeSkatteverket({ commitSubmitVatDeclaration: vat, commitSubmitAgi: vi.fn() })
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null })
|
||||
enqueue({ data: null, error: null }) // reject update
|
||||
|
||||
const op = makePendingOp({ params: { period_type: 'monthly', year: 2025, period: 3 } })
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.http_status).toBe(400)
|
||||
expect(result.error).toMatch(/rejected/i)
|
||||
})
|
||||
|
||||
it('missing params → 400 without resolving the extension service', async () => {
|
||||
const vat = vi.fn()
|
||||
registerFakeSkatteverket({ commitSubmitVatDeclaration: vat, commitSubmitAgi: vi.fn() })
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null })
|
||||
enqueue({ data: null, error: null }) // reject update
|
||||
|
||||
const op = makePendingOp({ params: { year: 2025 } }) // missing period_type + period
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.http_status).toBe(400)
|
||||
expect(vat).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -113,6 +113,13 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
|
||||
// invoice_payments row — sits next to link_invoice_voucher semantically;
|
||||
// both attach an existing booking to a different entity.
|
||||
link_transaction_journal_entry: 'medium',
|
||||
|
||||
// ── Skatteverket filing (PR5) ──────────────────────────────────────
|
||||
// External + irreversible once signed. Commit sends the declaration for
|
||||
// BankID signing; the user's signature in the browser is the filing act.
|
||||
// (getRiskLevel already defaults unknown → 'high'; explicit for intent.)
|
||||
submit_vat_declaration: 'high',
|
||||
submit_agi: 'high',
|
||||
}
|
||||
|
||||
export function getRiskLevel(operationType: string): RiskLevel {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Core ↔ Skatteverket-extension commit boundary.
|
||||
*
|
||||
* `lib/` cannot import from `@/extensions/` (CI guard, core-build.yml), so the
|
||||
* commit-side executors reach the Skatteverket extension only through the
|
||||
* registry-resolved `services` channel. This module defines the SHARED shape
|
||||
* the extension's commit services return and the recoverable-error class the
|
||||
* dispatcher uses to release an op back to `pending` — both live in core so the
|
||||
* extension (which may import core freely) and `commit.ts` agree on the
|
||||
* contract without core ever importing the extension.
|
||||
*/
|
||||
|
||||
/** Result returned by the extension's commitSubmitVatDeclaration / commitSubmitAgi. */
|
||||
export type SkvSubmitResult =
|
||||
| ({
|
||||
ok: true
|
||||
/** BankID signing deep-link. The op is "sent for signing", not filed. */
|
||||
signing_url: string
|
||||
} & Record<string, unknown>)
|
||||
| {
|
||||
ok: false
|
||||
/** Structured error code (see lib/errors/structured-errors.ts). */
|
||||
code: string
|
||||
http_status: number
|
||||
/**
|
||||
* true → the op is fine; the connection/flag/quota isn't. Release it back
|
||||
* to `pending` so the user can fix and re-approve the SAME op.
|
||||
* false → a wrong-data / SKV-business condition. Reject (consume) the op;
|
||||
* the user must regenerate and re-stage.
|
||||
*/
|
||||
recoverable: boolean
|
||||
error: string
|
||||
}
|
||||
|
||||
/** The two functions a fully-wired skatteverket extension exposes on `services`. */
|
||||
export interface SkatteverketCommitServices {
|
||||
commitSubmitVatDeclaration: (
|
||||
supabase: unknown,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
params: Record<string, unknown>,
|
||||
) => Promise<SkvSubmitResult>
|
||||
commitSubmitAgi: (
|
||||
supabase: unknown,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
params: Record<string, unknown>,
|
||||
) => Promise<SkvSubmitResult>
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by a commit executor when the failure is recoverable (extension
|
||||
* disabled, no SKV connection, rate-limited). The dispatcher catches it,
|
||||
* releases the atomic claim back to `pending`, and surfaces { error, code,
|
||||
* http_status } — mirroring the AccountsNotInChartError release path.
|
||||
*/
|
||||
export class SkatteverketRecoverableError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code: string,
|
||||
public readonly httpStatus: number,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'SkatteverketRecoverableError'
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user