Files
accounted/extensions/general/skatteverket/index.ts
T
MattssonandClaude Fable 5 f24b26a139 fix: similar-sweep currency remediation, security hardening and v1 API fixes (#1215)
* fix(security): gate replace_sie_import behind owner/admin membership

The RPC was SECURITY DEFINER with EXECUTE granted to PUBLIC and anon, no
company_members lookup, no auth.uid() reference and no unauthorized raise,
while setting gnubok.allow_delete to disarm the BFL immutability and
retention triggers. Any caller holding a company_id and an import id could
hard delete another tenant's verifikationer. Confirmed live in production.

Applies the same fail closed owner/admin guard that undo_sie_import already
carries (migration 20260624120000), resolving the actor from
COALESCE(p_user_id, auth.uid()) so it denies when the role is NULL, then
revokes EXECUTE from PUBLIC and anon. search_path and the raised
statement_timeout are restated, since CREATE OR REPLACE drops settings that
are not repeated.

userId is a required parameter on replaceSIEImport: the service client has a
NULL auth.uid(), so a caller without an explicit actor now fails to compile
rather than hitting the closed gate at runtime.

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

* fix(security): validate arcim OAuth callback state server side

The callback route is skipAuth and decoded the state parameter as plain
base64url JSON, trusting consentId and provider from it. A one time code was
minted at flow start and never read. An unauthenticated attacker who learned
a consent id could run an OAuth flow on their own provider account and post
the callback with a forged state, landing their tokens on another tenant's
consent, so the victim's next migration imported the attacker's ledger.

State is now an opaque randomBytes(32) pointer to a provider_otc row,
consumed by a single atomic UPDATE guarded on used_at IS NULL and
expires_at, so a replay loses the row lock race and updates nothing.
provider is read from provider_consents rather than trusted from the client.
provider_otc already existed for exactly this purpose and was never wired up.

Also scopes getConsent to an owning company, closing a cross tenant status
oracle where the preview and migrate paths echoed a consent's status before
the scoped check ran.

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

* fix(security): scope documents storage to company_id (phase A)

The documents bucket policies matched on auth.uid(), and upload keys were
documents/{userId}/..., so company membership was never consulted. Removing a
member revoked nothing: their session still authenticated and they kept
direct Storage read access to every receipt, supplier invoice and bank
statement they had uploaded. The same bug was fixed for sie-files in
20260416120000; this bucket was left behind.

Phase A is additive. Company scoped policies are added alongside the
uploader scoped ones, uploads move to documents/{companyId}/{userId}/..., and
reads accept either layout so nothing breaks mid migration. Phase C, which
drops the old policies, is gated on the backfill reporting zero remaining
legacy prefix objects.

The policy compares the company segment as text rather than casting to uuid
the way sie-files does: this bucket holds keys whose second segment is not a
uuid (MCP audit packages), and Postgres does not guarantee the bucket prefix
qual runs before the cast, so a planner reordering would raise 22P02 and fail
the whole query instead of filtering the row out.

deleteDocument now removes both candidate keys. Removing only the stored
pointer would leave a readable orphan copy of a document the user asked to
erase.

The backfill script is included but has never been run. It defaults to dry
run, refuses .env.local by name, and verifies each copy is readable and
SHA-256 identical before repointing the row.

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

* fix(security): enforce events:read scope and membership on /api/events

This was the only one of the three validateApiKey call sites with no
downstream guard: v1 and the MCP server both check scope and re-verify
company membership, this route did neither. An events:read scope existed and
was documented as gating the endpoint but was never called, so a legacy key
falling back to DEFAULT_SCOPES read the full log. The bound company id went
straight from the api_keys row into a service role query, so a key whose user
had been removed from the company kept reading.

Adds the scope check before any database access, re-verifies company_members
with archived_at IS NULL, honours test mode by stamping X-Gnubok-Mode instead
of ignoring it, applies minimisePayload so the pull surface can never return
a wider payload than the push surface, and replaces the three flat error
strings with the canonical envelope.

Test key reads are served rather than blocked: TEST_KEY_WRITE_BLOCKED is
gated on mutations in with-api-v1, so a read gets the same treatment as every
other v1 read endpoint.

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

* perf(bookkeeping): sweep remaining journal_entries!inner embeds

A previous refactor removed this pattern from lib/reports and introduced
fetchEntryLines, but the class was never swept. Seventeen sites remained and
had become the top application consumer of production database time:
measured across the resulting query shapes, 32,694 calls and 25,848 seconds
of execution, mean 790ms, with shapes averaging 2.6s and 3.0s and maxing at
7,962ms against the 8s statement_timeout, which surfaced to users as 500s on
the booking path.

PostgREST compiles an embed with filters on the embedded side into a
correlated INNER JOIN LATERAL with a parameterized LIMIT, which stops
Postgres reordering the join, so each query walked the whole
journal_entry_lines table across all tenants. Driving from the entries side
instead turns that into two indexed round trips.

Converted sites keep their existing shape: the helper reattaches the parent
entry under the same key the embed produced. Several conversions also remove
a latent silent truncation where an unpaginated query was capped at
PostgREST's 1000 row ceiling.

Two deliberate exceptions. The free text ilike legs of the MCP display query
stay on the embed, because each is capped at legLimit and that cap drives the
truncation contract the tool reports, while the helper is unbounded. The
accounts route moves to the existing get_account_usage_counts RPC instead,
since its embed was a head count and the helper returns rows.

commitEntry's write path is untouched: the change there is confined to the
read query of the pre-commit dimension rule check.

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

* fix(api): anchor v1 list cursors on created_at

Page two returned page one, forever, while still advertising a fresh
next_cursor. The three routes sorted by and encoded a Postgres date column,
which serializes as YYYY-MM-DD, but decodeDefaultCursor validates the cursor
timestamp as full ISO-8601 and returned null, so the keyset filter was never
applied and has_more never went false. An integrator syncing verifikat looped
on the newest rows indefinitely.

The transactions route already solved this and its comment names the trap;
the fix was never ported. All three now order and encode on created_at with
an id tie break, matching the transactions keyset predicate exactly.
ISO_TIMESTAMP is deliberately left alone: relaxing it would silently change
sort semantics on the route that currently works.

Default ordering therefore moves from business date to insert order. Every
business date is still on the row, and the invoices list gains date_from and
date_to filters so a date range is still reachable; the other two already had
them.

The tests use an in-memory PostgREST that actually evaluates the filters,
because the repo's pass-through mock cannot catch this class of bug: the bug
is that the filter is never sent. They walk to exhaustion with a hard
iteration cap, so an unterminated walk fails instead of hanging.

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

* fix(api): separate dry run from commit in the idempotency hash

The request hash was built from url.pathname, which excludes the query
string, so a dry run and its commit hashed identically. Following the flow
documented in dry-run.ts, re-issuing the request with the same
Idempotency-Key returned the cached preview with Idempotent-Replayed set and
wrote nothing, while reporting 200. An agent or integrator saw success for a
write that never happened.

dry_run is folded into the hash only when true, not as an unconditional
boolean. Including it as false would change the hash of every ordinary write,
and with a 24h idempotency TTL any key in flight across the deploy would fail
the request_hash comparison and 409 on a legitimate retry. Both hash call
sites now go through one shared helper so they cannot drift into a permanent
cache miss, and dry run responses are no longer stored at all.

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

* ci: install the Bedrock SDK out of tree in the compliance review

The Swedish accounting compliance gate had failed ten consecutive runs and so
was posting nothing. With --no-package-lock npm discarded the lockfile and
re-resolved the whole tree from package.json, floating @hookform/resolvers to
5.4.3, whose valibot ^1 peer conflicts with the pinned valibot 0.39.0.

Installing into the parent of the checkout resolves only that one package, so
an unrelated peer conflict can never take the gate down again. Node still
finds it because ESM bare specifiers walk up parent node_modules; NODE_PATH
would not have worked, as it is CommonJS only. --legacy-peer-deps was
rejected because it masks future genuine peer conflicts and still reifies the
full tree.

The same step's SDK version is aligned from 0.31.0 back to the 0.29.1 that
package.json and check:guards enforce after the streaming outage. That drift
went unnoticed because the pin guard only inspects package.json and the
lockfile, never workflow files.

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

* build(docker): generate crontabs from vercel.json

vercel.json defines 16 cron jobs; both Docker crontabs carried 9, and were
byte identical to each other. Self hosted deployments therefore never sent
recurring invoices, never dispatched webhooks and never cleaned up
idempotency keys. tax-deadlines also ran once a year on 2 January instead of
daily, and documents/verify weekly instead of daily.

Extension crons are included rather than excluded. The Dockerfile copies the
whole tree before building, so every extension cron route is compiled into
the image regardless of the enabled preset, and each returns 200 when its
extension is unconfigured, so curl -sf logs no failure. Two such entries were
already present in the crontab for extensions absent from the preset, which
settles the intent.

documents/verify is treated as drift rather than a self hosted concession:
the weekly cadence was present in the hosted crontab too, and the run is
capped at 200 documents walking a nulls-first queue, so weekly drains the
integrity queue seven times slower on a check that exists for BFL retention.

webhooks/dispatch keeps its per minute cadence, adding 1,440 requests a day
on self hosted. A gentler tick would silently stretch the first retry, since
the retry ladder opens at 60 seconds. SCHEDULE_OVERRIDES is the one line
place to change that.

A parity test asserts the path sets match minus a documented exclusion list,
and ratchets three cron routes that are currently scheduled nowhere so they
are named rather than silently rotting.

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

* chore(observability): add a provider agnostic error sink

There is no error tracking in this codebase: logs go to console and Vercel
retention and nowhere else, nothing alerts on the 16 cron jobs, and seven
code comments across lib, app, components and extensions asserted that Sentry
captures errors when Sentry is not a dependency. The two most recent bug
fixes on this repo were both discovered by customer email.

This adds the sink, not a vendor. No dependency is taken: the interface has a
no-op default and a registration point, so behaviour is unchanged until an
adapter is registered. Releases are tagged from the build id already inlined
by next.config.ts.

Redaction moved out of lib/logger.ts into a leaf module that both the logger
and the sink import, so there is one denylist and no path from application
data to a third party can skip the personnummer regex, including direct sink
calls that bypass the logger. That matters here because these logs carry
personnummer and financial data.

verifyCronSecret now reports its own 401s, which covers all 16 jobs without
touching a route file and catches the case where CRON_SECRET is rotated
without updating the scheduler and every job silently 401s forever. The
threshold is one failure rather than the backup alert's three: suppressing
the first occurrence is precisely how an outage stays invisible.

The seven misleading comments are corrected to describe what the code
actually does, including the two cases that still are not covered: the client
side one, since the sink is server side, and a warn level call that is not
forwarded.

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

* fix: remediate the 2026-07-26 similar-sweep findings across all surfaces

Resolves the ~150-finding sweep (dev_docs/similar-sweep-2026-07-26.md) with
one agent per finding; every behavioural fix carries a regression test proven
to fail at HEAD. Full status, corrections to the sweep, refusals and open
decisions in dev_docs/similar-sweep-2026-07-26-remediation-status.md.

Structural roots closed:
- resolveSekAmountOrNull(): honest SEK resolution refuses instead of booking
  1:1; four duplicated toSek closures now refuse via INVOICE_FX_RATE_MISSING
- ledger-line-amount.ts: journal_entry_lines.currency labels the document,
  not the amount; SQL pre-filter decoy proven and fixed
- sparse-patch.ts: .partial() does not strip .default() in Zod 4.4.3; the
  exploitable salary payslip-line PATCH and KPI preferences sinks fixed
- tests/schema: migration-replay phantom-column guard (13k+ refs, closed
  CHECK sets, onConflict targets); found 28 real defects, all fixed, all
  four baselines now empty
- three new ratchet guards: sek-labelled-amount, cross-extension-import,
  ungated-extension-route

Highlights: lawful VAT-rate set on all seven invoice surfaces (ML 6 kap),
RC input VAT mismatch wired on web + both MCP callers, missing-underlag
resource delegates to the shared RPC predicate, push-notifications consent
polarity fail-closed, deadlines undo honours requested state, silent-failure
and read-side-fabrication classes fixed across settings/KPI/inbox/Stripe/
Arcim/kassaflodesanalys, error-envelope stringification fixed at 10+ sites
with isSwedishUserMessage extended.

Also includes the parallel session's MCP invoice tools (update_invoice,
recurring schedules, invoice deliveries) which share files with the sweep
work and are verified green together.

13 new migrations are NOT applied anywhere; they apply via branch merge.
20260726120000 backfills 1247 supplier-invoice rows. pg tests for new
DDL are written but unrun (no local Postgres).

Verified: 11088 tests / 881 files green, tsc 0 non-test errors, lint 0
errors, check:guards passing, MCP payload 57475/57500.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(migrations): rename replace_sie_import migration off main's 20260726090000 version

origin/main shipped 20260726090000_agent_quota_rpc_caller_guard.sql; keeping
our replace_sie_import migration on the same version would abort the Supabase
apply with a schema_migrations_pkey duplicate at merge time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): remediate pre-publish deep-review findings across all slices

A 13-agent review of the full branch diff surfaced 1 critical, 5 high and
~45 further findings; this commit resolves them in one pass:

- replace_sie_import / undo_sie_import: p_user_id honored only for
  service_role callers; any other caller is pinned to auth.uid()
  (impersonation gate bypass), authz raise errcode 42501 mapped to a
  Swedish 403 in the route, new caller-guard migration for undo
- bulk_book_transactions refuses homogeneous non-SEK batches instead of
  writing foreign magnitudes into SEK ledger columns
- credit-note cap trigger: company-match on credited_invoice_id, no
  cross-tenant figures in exception text
- link_voucher RPCs resolve NULL invoice currency as SEK end to end
- personal-number ciphertext CHECK split into NOT VALID + VALIDATE
- same-currency foreign settlements clear 1510 at booking rate and book
  realized diff to 3960/7960; rate-less foreign write paths refuse
- receivables revaluation covers partially_paid and outstanding amounts
- period lock guard paginates candidates past the PostgREST 1000 cap
- documents: service-client storage removals after authz, dual-layout
  reads in integrity cron and archive export, backfill delete-source
  sweep actually deletes with hash verification and shared-key grouping
- invoice matching normalizes NULL/lowercase currencies (regression),
  duplicate candidates stop claiming amount matches they never ran
- match-invoice aborts on any booking failure (no paid-without-verifikat)
- refresh-exchange-rate reverts on concurrent booking (TOCTOU window)
- KPI preferences upsert arbiter aligned to the company-scoped constraint
- personnummer_last4 stripped from all salary responses incl. MCP tools
- worked-hours batch restores destroyed rows on conflict and error paths
- MCP: shared duplicate-claim builder (no more 'null kr'), short-circuit
  on tag_journal_lines overflow, auto_send schedules stage as high risk
- observability sink redacts emails/IBANs/API keys and keeps redacted
  stacks in prod; assorted small guards (safe-return-to /@, dry_run=True,
  cursor helper off-by-one, OAuth state TTL 10 min, arcim saveMappings
  call removed)

Full dispositions, deferred items and hand-verified accounting numbers
are documented in the PR body and DECISIONS.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(personnummer): implement masking and encryption for personal numbers with tests

* fix(review): address CI and compliance-bot findings for PR #1215

pg-real: the CI image's auth shim reads the legacy request.jwt.claim.role
GUC, so both service-role simulations (runAsServiceRole and the
invoice-delivery test's local helper) never satisfied auth.role() =
'service_role' and every legitimate p_user_id path failed closed; the
shared helper now sets both GUC shapes plus SET LOCAL ROLE with a
fail-loud sanity check, and the delivery test reuses it. The link-voucher
migration had recreated both RPCs from pre-rewrite file text,
reintroducing the NULL-unsafe membership pattern the
null-safe-tenant-guards ratchet bans; both guards now use
public.caller_is_company_member() with all currency changes preserved.

Compliance bots: the customers export now emits the standard masked form
instead of raw AES-256-GCM ciphertext in the Org-/personnummer column,
and maskCustomerRow returns a non-round-trippable placeholder on decrypt
failure instead of 500ing the list. MCP parity: gnubok_lock_period's
staging pre-check now runs the exact countUnbookedInPeriod the commit
path enforces (exported from period-service; local mirror deleted), and
gnubok_agi_status resolves AGI state run-scoped so a correction run no
longer renders as already filed.

Declined with evidence: PR-Agent's opening-balances null-zeroing concern
(all mergeable columns are NOT NULL with defaults per 20260713101000).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): address codex review findings on PR #1215

- restore 20260726140000 to its preview-recorded content and restate the
  NULL-safe tenant guard under 20260727130000: a recorded migration version
  never re-runs, so the in-place edit could not reach the preview branch
- replace toFixed() with sv-SE two-decimal formatting in the ROT/RUT cap
  warning texts and update the pinned test expectations
- drop the em dash in the fiscal-periods route comment
- strip trailing whitespace in import-existing.test.ts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(reports): raise timeout on real PDF render tests

renderToBuffer does real @react-pdf layout work and exceeds the 5s
default when the full suite saturates the CPU; tests pass in isolation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 03:34:56 +02:00

2662 lines
110 KiB
TypeScript

import crypto from 'crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import { NextResponse, after } from 'next/server'
import {
AGIKontrolleraHUSchema,
AGIKontrolleraIUSchema,
AGI_KONTROLLERA_MAX_BYTES,
} from '@/lib/salary/agi/kontrollera-schemas'
import { TimeoutError } from '@/lib/http/fetch-with-timeout'
import { requireCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { buildAuthorizeUrl, exchangeCodeForTokens, generatePkcePair } from './lib/oauth'
import { storeTokens, getTokens, deleteTokens, getTokenHealth } from './lib/token-store'
import { skvRequest, skvRequestWithAuth, SkatteverketAuthError, getSkatteverketEnvironment } from './lib/api-client'
import { writeSkatteverketAudit } from './lib/audit'
import { skvAuthCodeToStructured } from './lib/error-map'
import {
buildMomsuppgift,
buildAgiUnderlag,
type VatDeclarationPrep,
type AgiUnderlagPrep,
} from './lib/declaration-prep'
import { submitVatDeclarationChain } from './lib/vat-submit'
import { completeTaxDeadline } from '@/lib/deadlines/complete-tax-deadline'
import { getSystemAuthMode, isSystemAuthConfigured, getOmbudOrgNumber, getSystemCertInfo } from './lib/system-auth/config'
import { getConnection, markConnectionRevoked } from './lib/connection-store'
import { currentSkvEnvironment, resolveReadAuth } from './lib/resolve-auth'
import { probeCompanyGrants } from './lib/grant-probe'
import { formatRedovisare } from '@/lib/skatteverket/format'
import { createExtensionContext } from '@/lib/extensions/context-factory'
import type { SkvSubmitResult } from '@/lib/pending-operations/skatteverket-commit'
import {
agiPostUnderlag,
agiGetKontrollresultat,
agiSparaUnderlag,
agiAvbrytUnderlag,
agiTaBortSparadInlamning,
agiSkapaGranskningsunderlag,
agiGetKvittenser,
agiLasPeriod,
agiLasUppPeriod,
agiKontrolleraHU,
agiKontrolleraIU,
} from './lib/agi-client'
import { syncSkattekonto, SKATTEKONTO_BALANCE_SNAPSHOT_KEY, SKATTEKONTO_LAST_SYNCED_AT_KEY } from './lib/skattekonto-sync'
import { runPostConnectRefresh } from './lib/post-connect-refresh'
import { bokforSkattekontoTransaction, SkattekontoBookingError } from './lib/skattekonto-booking'
import { handleSkattekontoDriftDetected } from './lib/skattekonto-drift-email'
import { handleSkattekontoConnectionExpired } from './lib/connection-expired-notification'
import {
findMatchCandidates,
findMatchSuggestionsBulk,
matchSkattekontoToEntry,
SkattekontoMatchError,
} from './lib/skattekonto-match'
import { splitTransactions } from './lib/skattekonto-buckets'
import type { SkattekontoBalanceSnapshot } from './types'
import type { VatPeriodType } from '@/types'
import { createLogger } from '@/lib/logger'
const log = createLogger('skatteverket')
/**
* Skatteverket integration extension.
*
* Enables filing momsdeklaration (VAT declaration) and arbetsgivardeklaration
* (AGI), plus Skattekonto saldo sync. Users authenticate with BankID via the
* `per` (e-legitimation) OAuth2 flow.
*
* Required environment variables:
* - SKATTEVERKET_OAUTH2_CLIENT_ID
* - SKATTEVERKET_OAUTH2_CLIENT_SECRET
* - SKATTEVERKET_APIGW_CLIENT_ID
* - SKATTEVERKET_APIGW_CLIENT_SECRET
* - SKATTEVERKET_TOKEN_ENCRYPTION_KEY (openssl rand -base64 32; never reuse
* the test-env key in prod)
*
* Optional:
* - SKATTEVERKET_ENV : 'test' | 'production'.
* Drives security-relevant
* limits (AGI payload size:
* 100 MB test vs 300 MB prod).
* Defaults to 'test' (stricter)
* when unset or unrecognised.
* MUST be set explicitly in
* every deployment manifest.
* - SKATTEVERKET_OAUTH_BASE_URL : defaults to test
* - SKATTEVERKET_API_BASE_URL : momsdeklaration; defaults to test
* - SKATTEVERKET_AGD_INLAMNING_API_BASE_URL : AGI inlämning; defaults to test
* - SKATTEVERKET_AGD_PERIOD_API_BASE_URL : AGI period mgmt; defaults to test
* - SKATTEVERKET_SKATTEKONTO_API_BASE_URL : Skattekonto; defaults to test
* - SKATTEVERKET_DISABLED=true : emergency kill switch
*
* ─── Production cutover checklist ─────────────────────────────────────────
* Before flipping the env URLs to prod, the following has to land first
* (most are external blockers):
*
* 1. Register a prod OAuth2 client in Skatteverket's developer portal
* (separate from the test client). Requires a signed integrationsavtal.
* 2. Order APIGW prod credentials (separate ärende).
* 3. Register the prod redirect URI:
* `${NEXT_PUBLIC_APP_URL}/api/extensions/ext/skatteverket/callback`.
* 4. Request scopes: agd:skicka, agd:lasa, skattekonto:lasa, moms:skicka.
* 5. Pass Skatteverket's godkännandetest (they validate a few real AGI
* submissions in their test tenant before granting prod access).
* 6. Generate a fresh SKATTEVERKET_TOKEN_ENCRYPTION_KEY (rotate from test).
* 7. Set the prod base URLs:
* SKATTEVERKET_API_BASE_URL=https://api.skatteverket.se/momsdeklaration/v1
* SKATTEVERKET_AGD_INLAMNING_API_BASE_URL=https://api.skatteverket.se/arbetsgivardeklaration/inlamning/v1
* SKATTEVERKET_AGD_PERIOD_API_BASE_URL=https://api.skatteverket.se/arbetsgivardeklaration/hanteraredovisningsperiod/v1
* SKATTEVERKET_SKATTEKONTO_API_BASE_URL=https://api.skatteverket.se/beskattning/skattekonto/v2
* SKATTEVERKET_OAUTH_BASE_URL=https://peroauth2.skatteverket.se/oauth2/v1/per
* (per-flow prod host, verified resolving; oauth2.skatteverket.se does not exist)
* 8. Wire an observability provider (lib/observability has the sink but no
* adapter is registered yet) and verify it alerts on
* /api/extensions/ext/skatteverket/* 5xx.
* 9. Verify 7-year retention of `agi_declarations.xml_content` +
* `kvittensnummer` (BFL 7 kap.).
* 10. Run a single AGI end-to-end against test on a real client before
* switching that client over.
*
* ─── System auth (ombud + org certificate) cutover checklist ──────────────
* The hybrid model's background-read credentials. All code ships behind
* SKATTEVERKET_SYSTEM_AUTH_MODE=off; flipping to shadow/on requires:
*
* 1. Skatteverket's CCG/org-flow docs (token endpoint URL, mechanism
* mTLS vs private_key_jwt, scope names, whether APIGW headers persist,
* whether resource calls need the client cert).
* 2. Organisationscertifikat from Expisoft (test + prod) for Accounted's
* org number, base64-wrapped into SKATTEVERKET_SYSTEM_CERT_PEM_B64 /
* _KEY_PEM_B64.
* 3. Bilateral avtal per API for the org flow (skattekonto read, AGI read,
* momsdeklaration ombud) + APIGW subscriptions for the system client.
* 4. Set SKATTEVERKET_SYSTEM_OAUTH_TOKEN_URL, _SCOPES, _CLIENT_ID,
* _AUTH_MECHANISM per the docs; SKATTEVERKET_OMBUD_ORG_NUMBER =
* Accounted's org number (shown to users in the grant instructions).
* 5. Validate grant-probe.ts classification against real sandbox 403
* bodies (shadow mode in the test environment first).
* 6. Godkännandetest per API, then SKATTEVERKET_SYSTEM_AUTH_MODE=on in
* prod. User tokens remain the fallback indefinitely.
*
* The /status endpoint reports which environment is active so the UI can
* surface a Testmiljö / Produktion badge.
*/
const AGI_WRITE_ROLES = new Set(['owner', 'admin', 'member'])
/**
* Paywall gate for routes that talk to Skatteverket's API. The declaration
* FILE download is always free (manual filing is never blocked); the direct
* API interaction (connect, validate, draft, lock, submit, sync) is the paid
* convenience. Returns null when entitled, a 403 capability_blocked response
* otherwise. Unlock (DELETE /declaration/lock) is deliberately NOT gated so a
* lapsed company can always recover a draft it locked while entitled.
*/
async function requireSkvCapability(ctx: ExtensionContext): Promise<NextResponse | null> {
return requireCapability(ctx.supabase, ctx.companyId, CAPABILITY.skatteverket)
}
/**
* Defense-in-depth RBAC check for AGI write/validate endpoints. Ctx
* presence alone (set by middleware) only confirms the user is signed in
* and has a resolved company; it does NOT prove they are entitled to
* submit tax declarations for that company. Viewers must be blocked.
*
* Returns null on success, a NextResponse on failure.
*/
async function requireAgiWriteRole(ctx: ExtensionContext): Promise<NextResponse | null> {
const { data, error } = await ctx.supabase
.from('company_members')
.select('role')
.eq('company_id', ctx.companyId)
.eq('user_id', ctx.userId)
.maybeSingle()
if (error) {
return NextResponse.json(
{ error: 'Behörighetskontroll misslyckades.' },
{ status: 500 },
)
}
if (!data?.role || !AGI_WRITE_ROLES.has(data.role as string)) {
return NextResponse.json(
{ error: 'Otillräcklig behörighet för att lämna in AGI för det här företaget.' },
{ status: 403 },
)
}
return null
}
/**
* Base URL for the OAuth redirect_uri registered with Skatteverket in
* Utvecklarportalen. Registration changes there are slow, so after the
* user-facing app moved to app.accounted.se the redirect_uri stays pinned
* to the legacy domain via NEXT_PUBLIC_SKV_OAUTH_BASE_URL (hosted value:
* https://app.gnubok.se). Self-hosted deployments leave it unset and the
* regular app URL is used.
*/
function getSkvOauthBaseUrl(): string {
return (
process.env.NEXT_PUBLIC_SKV_OAUTH_BASE_URL ||
process.env.NEXT_PUBLIC_APP_URL ||
'http://localhost:3000'
)
}
export const skatteverketExtension: Extension = {
id: 'skatteverket',
name: 'Skatteverket Integration',
version: '1.0.0',
settingsPanel: {
label: 'Skatteverket',
path: '/settings/account',
},
apiRoutes: [
// ── OAuth: Start authorization ──────────────────────────────────
// Builds the Skatteverket OAuth2 authorize URL and redirects the user
// to BankID login. Stores state token in extension settings for CSRF validation.
{
method: 'GET',
path: '/authorize',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
const blocked = await requireSkvCapability(ctx)
if (blocked) return blocked
const state = crypto.randomUUID()
const redirectUri = `${getSkvOauthBaseUrl()}/api/extensions/ext/skatteverket/callback`
// Optional: where to send the user after the BankID round-trip.
// Allowlisted to internal in-app paths to avoid open-redirect abuse.
const url = new URL(request.url)
const requestedReturn = url.searchParams.get('return_to')
const returnTo =
requestedReturn && requestedReturn.startsWith('/') && !requestedReturn.startsWith('//')
? requestedReturn
: null
// Generate PKCE pair: verifier persisted server-side, challenge sent
// to SKV. Some SKV per-flow client configurations issue revoked-on-use
// tokens unless PKCE is present, so we always send it.
const pkce = generatePkcePair()
// Store state for CSRF validation in callback. The user id is stored
// alongside it because the callback runs on the OAuth host (see
// getSkvOauthBaseUrl), where the browser carries no session cookies
// once the user-facing app lives on its own domain.
await ctx.settings.set('oauth_state', state)
await ctx.settings.set('oauth_user_id', ctx.userId)
await ctx.settings.set('oauth_redirect_uri', redirectUri)
await ctx.settings.set('oauth_code_verifier', pkce.verifier)
if (returnTo) await ctx.settings.set('oauth_return_to', returnTo)
else await ctx.settings.clear('oauth_return_to')
const authorizeUrl = buildAuthorizeUrl(redirectUri, state, {
codeChallenge: pkce.challenge,
})
return NextResponse.redirect(authorizeUrl)
},
},
// ── OAuth: Callback ─────────────────────────────────────────────
// Receives the auth code from Skatteverket after BankID login.
// Exchanges code for tokens immediately (5-minute code expiry).
// skipAuth: true; browser redirect from Skatteverket. We handle
// user identification via the stored state token + Supabase session.
{
method: 'GET',
path: '/callback',
skipAuth: true,
handler: async (request: Request) => {
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
const url = new URL(request.url)
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
const error = url.searchParams.get('error')
// Injection-safety invariants: appUrl comes from NEXT_PUBLIC_APP_URL
// (deployment configuration, never user input), and jsLiteral
// JSON-encodes and escapes `<` so embedded values cannot break out of
// the script context. The per-response CSP nonce below is defense in
// depth on top of that: even injected markup could never execute.
const jsLiteral = (value: unknown) =>
JSON.stringify(value ?? '').replace(/</g, '\\u003c')
// CSP allows only the nonce-carrying inline script; everything else
// is blocked. Cache-Control: no-store because the callback URL
// carries a one-shot authorization code and must never be cached.
const responseHeaders = (nonce: string) => ({
'Content-Type': 'text/html; charset=utf-8',
'Content-Security-Policy':
`default-src 'none'; script-src 'nonce-${nonce}'; base-uri 'none'; form-action 'none'`,
'Cache-Control': 'no-store',
})
// Build an HTML response that detects whether we're running inside an
// OAuth popup. If `window.opener` exists, post a message back to the
// parent and close the popup. Otherwise fall back to a plain redirect
// (preserves the legacy non-popup connect flow). The fallback uses
// location.replace so this callback URL (whose code and state are
// consumed) drops out of history: navigating Back from the landing
// page must not re-run the callback into a guaranteed CSRF error.
const respondWithSuccess = (fallbackPath: string) => {
const nonce = crypto.randomUUID()
const html = `<!DOCTYPE html><html><body><script nonce="${nonce}">
if (window.opener) {
window.opener.postMessage({ type: 'skatteverket-oauth-success' }, ${jsLiteral(appUrl)});
window.close();
} else {
window.location.replace(${jsLiteral(`${appUrl}${fallbackPath}`)});
}
</script><p>Anslutningen lyckades. Du kan stänga denna flik.</p></body></html>`
return new Response(html, {
status: 200,
headers: responseHeaders(nonce),
})
}
const respondWithError = (reason: string, fallbackPath: string) => {
const nonce = crypto.randomUUID()
const escapedReason = reason
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
const html = `<!DOCTYPE html><html><body><script nonce="${nonce}">
if (window.opener) {
window.opener.postMessage({ type: 'skatteverket-oauth-error', reason: ${jsLiteral(reason)} }, ${jsLiteral(appUrl)});
window.close();
} else {
window.location.replace(${jsLiteral(`${appUrl}${fallbackPath}`)});
}
</script><p>Anslutningen misslyckades: ${escapedReason}</p></body></html>`
return new Response(html, {
status: 200,
headers: responseHeaders(nonce),
})
}
if (error) {
const desc = url.searchParams.get('error_description') || 'Okänt fel'
return respondWithError(
desc,
`/reports?tab=vat-declaration&skv_error=${encodeURIComponent(desc)}`,
)
}
if (!code || !state) {
return respondWithError(
'Saknar auktoriseringskod',
`/reports?tab=vat-declaration&skv_error=${encodeURIComponent('Saknar auktoriseringskod')}`,
)
}
// This callback is served on the OAuth host (see getSkvOauthBaseUrl),
// where the browser has no session cookies once the user-facing app
// lives on its own domain. The flow is resolved entirely from the
// state token: /authorize stored state, user id, redirect_uri and
// PKCE verifier keyed on company_id, and the state value is an
// unguessable single-use UUID, so the bare lookup by value doubles
// as the CSRF check.
const { createClient, createServiceClient } = await import('@/lib/supabase/server')
const db = createServiceClient()
// States are single-use and short-lived: the recency bound both
// caps how long a leaked/phished authorize URL stays completable
// (the row expires ten minutes after /authorize refreshed it) and
// keeps the row set far below PostgREST's silent 1000-row cap.
// value is jsonb, so equality is matched in JS rather than in the
// PostgREST filter, where JSON serialization rules would apply.
const stateCutoff = new Date(Date.now() - 10 * 60 * 1000).toISOString()
const { data: stateRows, error: stateError } = await db
.from('extension_data')
.select('company_id, value')
.eq('extension_id', 'skatteverket')
.eq('key', 'oauth_state')
.gte('updated_at', stateCutoff)
if (stateError) {
log.error('oauth state lookup failed', stateError)
return respondWithError(
'Ett tekniskt fel uppstod. Försök igen.',
`/reports?tab=vat-declaration&skv_error=${encodeURIComponent('Ett tekniskt fel uppstod')}`,
)
}
const stateMatch = (stateRows ?? []).find((row) => row.value === state)
if (!stateMatch) {
return respondWithError(
'Ogiltig state-parameter (CSRF)',
`/reports?tab=vat-declaration&skv_error=${encodeURIComponent('Ogiltig state-parameter (CSRF)')}`,
)
}
const companyId = stateMatch.company_id as string
const readSetting = async (key: string): Promise<string | null> => {
const { data } = await db
.from('extension_data')
.select('value')
.eq('company_id', companyId)
.eq('extension_id', 'skatteverket')
.eq('key', key)
.maybeSingle()
return (data?.value as string | null) ?? null
}
// Flows that started before oauth_user_id shipped ran on the same
// domain as the app and still carry session cookies; fall back to
// those so in-flight connects survive the deploy boundary.
let userId = await readSetting('oauth_user_id')
if (!userId) {
const cookieClient = await createClient()
const { data: { user } } = await cookieClient.auth.getUser()
userId = user?.id ?? null
}
if (!userId) {
return respondWithError(
'Sessionen har gått ut. Stäng fliken och försök ansluta igen.',
`/reports?tab=vat-declaration&skv_error=${encodeURIComponent('Sessionen har gått ut')}`,
)
}
// Defense in depth for the service-role write below: the stored
// user must still be a member of the company that initiated the
// flow (membership can be revoked between /authorize and this
// callback, and RLS no longer backstops the write). Checked before
// the exchange so a rejected flow does not burn the one-shot
// authorization code. (#1091)
const { data: membership } = await db
.from('company_members')
.select('user_id')
.eq('company_id', companyId)
.eq('user_id', userId)
.maybeSingle()
if (!membership) {
return respondWithError(
'Behörighet saknas för företaget',
`/reports?tab=vat-declaration&skv_error=${encodeURIComponent('Behörighet saknas för företaget')}`,
)
}
const redirectUri = (await readSetting('oauth_redirect_uri')) ||
`${getSkvOauthBaseUrl()}/api/extensions/ext/skatteverket/callback`
// Retrieve the PKCE verifier stored in /authorize. Optional only for
// backward compatibility with in-flight flows that started before the
// PKCE rollout: once those drain, this can be made required.
const codeVerifier = (await readSetting('oauth_code_verifier')) || undefined
// Optional in-app destination set by /authorize?return_to=...
const returnTo = await readSetting('oauth_return_to')
const successPath = returnTo
? `${returnTo}${returnTo.includes('?') ? '&' : '?'}skv_connected=true`
: `/reports?tab=vat-declaration&skv_connected=true`
const errorPath = (msg: string) =>
returnTo
? `${returnTo}${returnTo.includes('?') ? '&' : '?'}skv_error=${encodeURIComponent(msg)}`
: `/reports?tab=vat-declaration&skv_error=${encodeURIComponent(msg)}`
try {
const tokens = await exchangeCodeForTokens(code, redirectUri, codeVerifier)
await storeTokens(db, userId, tokens, companyId)
// Clean up CSRF state + the one-shot user id/return_to/PKCE verifier.
await db
.from('extension_data')
.delete()
.eq('company_id', companyId)
.eq('extension_id', 'skatteverket')
.in('key', ['oauth_state', 'oauth_user_id', 'oauth_return_to', 'oauth_code_verifier'])
// Refresh Skatteverket-derived data AFTER the response is sent.
// Right-after-consent is still the one reliable window for a
// personal-token fetch (SKV per-flow tokens live ~65 minutes), but
// the sync (skattekonto fetch + AGI auto-settle + kvittens
// re-checks) can take tens of seconds. This handler previously
// awaited it, which held the redirect open while the popup kept
// displaying SKV's already-consumed consent page; users read that
// as "I approved and nothing happened". The eager promise +
// after() pattern (mirrors the enable-banking finalize page) sends
// the success page immediately and keeps the serverless function
// alive until the refresh settles; the connect panels do a delayed
// status refetch to pick up the synced data. Best-effort: a
// refresh failure must never fail the connect that just succeeded.
const refreshPromise = runPostConnectRefresh(db, userId, companyId)
.then(() => undefined)
.catch((refreshErr) => {
log.error('post-connect refresh failed', refreshErr, { companyId, userId })
})
try {
after(() => refreshPromise)
} catch {
// Outside a request scope (unit tests, plain node server): the
// eager promise still drives the refresh to completion.
}
return respondWithSuccess(successPath)
} catch (err) {
console.error('[skatteverket] Token exchange failed:', err)
// The ephemeral flow rows must not outlive the flow: oauth_user_id
// in particular holds a user identity and serves no purpose once
// the exchange has failed (#1090). Best-effort: a cleanup failure
// must not mask the exchange error shown to the user.
try {
await db
.from('extension_data')
.delete()
.eq('company_id', companyId)
.eq('extension_id', 'skatteverket')
.in('key', ['oauth_state', 'oauth_user_id', 'oauth_return_to', 'oauth_code_verifier'])
} catch (cleanupErr) {
log.error('oauth state cleanup after failed exchange failed', cleanupErr, { companyId })
}
// BankID auth codes expire after 5 minutes. Surface timeouts distinctly
// so the user retries quickly instead of exhausting the code window.
const message = err instanceof TimeoutError
? 'Tidsgränsen mot Skatteverket överskreds: försök igen med BankID'
: err instanceof Error
? err.message
: 'Token exchange misslyckades'
return respondWithError(message, errorPath(message))
}
},
},
// ── Connection status ───────────────────────────────────────────
{
method: 'GET',
path: '/status',
handler: async (_request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
const tokens = await getTokens(ctx.supabase, ctx.userId)
const environment = getSkatteverketEnvironment()
const disabled = (process.env.SKATTEVERKET_DISABLED ?? '').toLowerCase() === 'true'
if (!tokens) {
return NextResponse.json({ connected: false, environment, disabled })
}
const expired = tokens.expires_at < Date.now()
const canRefresh = tokens.refresh_token !== null && tokens.refresh_count < 10
// Persisted health, written by the crons when they hit a terminal
// auth state. Lets the settings panel prompt for re-consent
// proactively instead of only after a live failure.
const health = await getTokenHealth(ctx.supabase, ctx.userId)
return NextResponse.json({
connected: true,
expired,
canRefresh,
needsReconsent: health?.status === 'needs_reconsent',
lastErrorCode: health?.last_error_code ?? null,
scope: tokens.scope,
expiresAt: new Date(tokens.expires_at).toISOString(),
environment,
disabled,
})
},
},
// ── Disconnect ──────────────────────────────────────────────────
{
method: 'POST',
path: '/disconnect',
handler: async (_request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
await deleteTokens(ctx.supabase, ctx.userId)
return NextResponse.json({ success: true })
},
},
// ══════════════════════════════════════════════════════════════
// System connection (ombud + organization certificate)
//
// The hybrid auth model's per-company side: the user grants Accounted's
// org number a behorighet at Skatteverket's Ombud och behorigheter
// e-service (one-time BankID signature), we verify it with a probe on
// SYSTEM credentials, and background reads stop depending on the
// 65-minute personal token. All of it is inert until
// SKATTEVERKET_SYSTEM_AUTH_MODE is switched on.
// ══════════════════════════════════════════════════════════════
// ── System connection: status + instructions ────────────────────
{
method: 'GET',
path: '/system-connection',
handler: async (_request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
const blocked = await requireSkvCapability(ctx)
if (blocked) return blocked
const mode = getSystemAuthMode()
const available = mode !== 'off' && isSystemAuthConfigured()
if (!available) {
return NextResponse.json({ available: false, mode })
}
const connection = await getConnection(ctx.companyId, currentSkvEnvironment())
return NextResponse.json({
available: true,
mode,
environment: currentSkvEnvironment(),
// What the user grants the behorigheter to, plus where.
ombud_org_number: getOmbudOrgNumber(),
grant_url: 'https://skatteverket.se/ombud',
behorigheter: [
{ key: 'lasombud', label: 'Juridiskt läsombud' },
{ key: 'moms_ombud', label: 'Momsdeklaration, ombud' },
],
cert: getSystemCertInfo(),
connection,
})
},
},
// ── System connection: verify (probe the grants) ────────────────
{
method: 'POST',
path: '/system-connection/verify',
handler: async (_request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
const blocked = await requireSkvCapability(ctx)
if (blocked) return blocked
const roleBlocked = await requireAgiWriteRole(ctx)
if (roleBlocked) return roleBlocked
if (getSystemAuthMode() === 'off' || !isSystemAuthConfigured()) {
return NextResponse.json(
{ error: 'Systemanslutningen är inte aktiverad i denna miljö.' },
{ status: 503 }
)
}
// Manual probes are rate limited: one per minute per company.
const existing = await getConnection(ctx.companyId, currentSkvEnvironment())
if (existing?.last_probe_at && Date.now() - new Date(existing.last_probe_at).getTime() < 60_000) {
return NextResponse.json(
{ error: 'Vänta en minut mellan verifieringar.', connection: existing },
{ status: 429 }
)
}
const { data: settings } = await ctx.supabase
.from('company_settings')
.select('org_number, entity_type')
.eq('company_id', ctx.companyId)
.single()
if (!settings?.org_number) {
return NextResponse.json(
{ error: 'Organisationsnummer saknas. Ange det under Inställningar först.' },
{ status: 400 }
)
}
const orgNumber = formatRedovisare(
settings.org_number as string,
settings.entity_type as 'enskild_firma' | 'aktiebolag'
)
try {
const result = await probeCompanyGrants(ctx.companyId, orgNumber, ctx.userId)
await writeSkatteverketAudit(ctx, {
endpoint: 'system-connection/verify',
agRegistreradId: orgNumber,
outcome: 'ok',
})
return NextResponse.json({
data: {
connection: result.connection,
// Spelled-out per-behorighet outcome so the UI can say
// "läsombud OK, momsbehörighet saknas fortfarande".
lasombud: result.lasombud,
moms_ombud: result.momsOmbud,
},
})
} catch (err) {
return handleSkvError(err)
}
},
},
// ── System connection: revoke locally ───────────────────────────
{
method: 'DELETE',
path: '/system-connection',
handler: async (_request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
const roleBlocked = await requireAgiWriteRole(ctx)
if (roleBlocked) return roleBlocked
// Marks the local row revoked (kept for history). Withdrawing the
// actual behorighet happens at skatteverket.se; this only stops us
// from using system credentials for the company.
await markConnectionRevoked(ctx.companyId, currentSkvEnvironment())
return NextResponse.json({ success: true })
},
},
// ── Validate declaration (dry run) ──────────────────────────────
// Sends momsuppgift to Skatteverket's /kontrollera endpoint.
// Returns ERROR/WARNING/OK without saving anything.
{
method: 'POST',
path: '/declaration/validate',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
const blocked = await requireSkvCapability(ctx)
if (blocked) return blocked
try {
const { redovisare, redovisningsperiod, momsuppgift } =
await parseDeclarationRequest(request, ctx)
console.log('[skatteverket] Validating:', {
redovisare,
redovisningsperiod,
momsuppgift: JSON.stringify(momsuppgift),
})
const response = await skvRequest(
ctx.supabase,
ctx.userId,
'POST',
`/kontrollera/${redovisare}/${redovisningsperiod}`,
momsuppgift
)
if (!response.ok) {
const text = await response.text()
console.error('[skatteverket] Validate error:', response.status, text)
return NextResponse.json(
{ error: `Skatteverket svarade med ${response.status}: ${text}` },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json({ data })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── Save draft ──────────────────────────────────────────────────
// Saves momsuppgift to Skatteverket's "Eget utrymme".
// Returns validation results. Optionally lock for signing.
{
method: 'POST',
path: '/declaration/draft',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
const blocked = await requireSkvCapability(ctx)
if (blocked) return blocked
try {
const { redovisare, redovisningsperiod, momsuppgift } =
await parseDeclarationRequest(request, ctx)
console.log('[skatteverket] Sending draft:', {
redovisare,
redovisningsperiod,
momsuppgift: JSON.stringify(momsuppgift),
})
const response = await skvRequest(
ctx.supabase,
ctx.userId,
'POST',
`/utkast/${redovisare}/${redovisningsperiod}`,
momsuppgift
)
if (!response.ok) {
const text = await response.text()
console.error('[skatteverket] Draft error:', response.status, text)
return NextResponse.json(
{ error: `Skatteverket svarade med ${response.status}: ${text}` },
{ status: response.status }
)
}
const data = await response.json()
// Track submission status
await ctx.settings.set(
`submission_${redovisningsperiod}`,
JSON.stringify({
status: 'draft_saved',
redovisare,
redovisningsperiod,
kontrollresultat: data.kontrollresultat,
updatedAt: new Date().toISOString(),
})
)
return NextResponse.json({ data })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── Fetch draft ─────────────────────────────────────────────────
{
method: 'GET',
path: '/declaration/draft',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
try {
const { redovisare, redovisningsperiod } = parseQueryParams(request, ctx)
const response = await skvRequest(
ctx.supabase,
ctx.userId,
'GET',
`/utkast/${redovisare}/${redovisningsperiod}`
)
if (response.status === 404) {
return NextResponse.json({ data: null })
}
if (!response.ok) {
const text = await response.text()
return NextResponse.json(
{ error: `Skatteverket svarade med ${response.status}: ${text}` },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json({ data })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── Delete draft ────────────────────────────────────────────────
{
method: 'DELETE',
path: '/declaration/draft',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
try {
const { redovisare, redovisningsperiod } = parseQueryParams(request, ctx)
const response = await skvRequest(
ctx.supabase,
ctx.userId,
'DELETE',
`/utkast/${redovisare}/${redovisningsperiod}`
)
if (response.status !== 204 && !response.ok) {
const text = await response.text()
return NextResponse.json(
{ error: `Skatteverket svarade med ${response.status}: ${text}` },
{ status: response.status }
)
}
await ctx.settings.clear(`submission_${redovisningsperiod}`)
return NextResponse.json({ success: true })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── Lock draft for signing ──────────────────────────────────────
// Returns a signeringslänk (deep link) that the user opens
// in a new tab to sign with BankID on Skatteverket's site.
{
method: 'PUT',
path: '/declaration/lock',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
const blocked = await requireSkvCapability(ctx)
if (blocked) return blocked
try {
const { redovisare, redovisningsperiod } = parseQueryParams(request, ctx)
const response = await skvRequest(
ctx.supabase,
ctx.userId,
'PUT',
`/las/${redovisare}/${redovisningsperiod}`
)
if (!response.ok) {
const text = await response.text()
return NextResponse.json(
{ error: `Skatteverket svarade med ${response.status}: ${text}` },
{ status: response.status }
)
}
const data = await response.json()
await ctx.settings.set(
`submission_${redovisningsperiod}`,
JSON.stringify({
status: 'draft_locked',
redovisare,
redovisningsperiod,
signeringsLank: data.signeringsLank,
updatedAt: new Date().toISOString(),
})
)
return NextResponse.json({ data })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── Unlock draft ────────────────────────────────────────────────
{
method: 'DELETE',
path: '/declaration/lock',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
try {
const { redovisare, redovisningsperiod } = parseQueryParams(request, ctx)
const response = await skvRequest(
ctx.supabase,
ctx.userId,
'DELETE',
`/las/${redovisare}/${redovisningsperiod}`
)
if (response.status !== 204 && !response.ok) {
const text = await response.text()
return NextResponse.json(
{ error: `Skatteverket svarade med ${response.status}: ${text}` },
{ status: response.status }
)
}
await ctx.settings.set(
`submission_${redovisningsperiod}`,
JSON.stringify({
status: 'draft_saved',
redovisare,
redovisningsperiod,
updatedAt: new Date().toISOString(),
})
)
return NextResponse.json({ success: true })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── One-click submit (kontrollera -> utkast -> lås) ─────────────
// The whole "skicka för signering" chain behind one button. Validation
// errors abort before anything is written to Eget utrymme; a lock
// failure leaves the saved draft in place and says so (draft_saved),
// so the UI can offer a lock-only retry instead of a full re-submit.
{
method: 'POST',
path: '/declaration/submit',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
const blocked = await requireSkvCapability(ctx)
if (blocked) return blocked
try {
const body = (await request.json()) as {
periodType?: VatPeriodType
year?: number
period?: number
fiscalPeriodId?: string
}
const { periodType, year, period, fiscalPeriodId } = body
if (!periodType || !year || !period) {
return NextResponse.json(
{ error: 'Saknar obligatoriska fält: periodType, year, period' },
{ status: 400 }
)
}
const result = await submitVatDeclarationChain(
ctx,
{ periodType, year, period, fiscalPeriodId },
{ validate: true }
)
if (!result.ok) {
return NextResponse.json(
{
error: result.error,
stage: result.stage,
draft_saved: result.draftSaved,
kontrollResultat: result.kontrollresultat,
},
{ status: result.httpStatus }
)
}
return NextResponse.json({
data: {
signeringsLank: result.signingUrl,
redovisare: result.redovisare,
redovisningsperiod: result.redovisningsperiod,
kontrollResultat: result.kontrollresultat,
},
})
} catch (err) {
return handleSkvError(err)
}
},
},
// ── Fetch submitted declaration ─────────────────────────────────
{
method: 'GET',
path: '/declaration/submitted',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
try {
const { redovisare, redovisningsperiod } = parseQueryParams(request, ctx)
// resolveReadAuth: post-signing checks should outlive the user's
// 65-minute session when the company has a moms_ombud grant.
const resolved = await resolveReadAuth(ctx.supabase, ctx.companyId, {
requires: 'moms_ombud',
userId: ctx.userId,
})
if (!resolved.ok) {
return NextResponse.json(
{ error: 'Inte ansluten till Skatteverket.', code: 'NOT_CONNECTED' },
{ status: 401 }
)
}
const response = await skvRequestWithAuth(
resolved.auth,
'GET',
`/inlamnat/${redovisare}/${redovisningsperiod}`
)
if (response.status === 404) {
return NextResponse.json({ data: null })
}
if (!response.ok) {
const text = await response.text()
return NextResponse.json(
{ error: `Skatteverket svarade med ${response.status}: ${text}` },
{ status: response.status }
)
}
const data = await response.json()
// A non-null inlamnat means the declaration is filed: complete the
// period's moms deadline. Best-effort, gated on the caller passing
// the picker params (older clients omit them).
if (data) {
await completeVatDeadlineFromRequest(request, ctx, 'submitted')
}
return NextResponse.json({ data })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── Fetch decided declaration ───────────────────────────────────
{
method: 'GET',
path: '/declaration/decided',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
try {
const { redovisare, redovisningsperiod } = parseQueryParams(request, ctx)
const resolved = await resolveReadAuth(ctx.supabase, ctx.companyId, {
requires: 'moms_ombud',
userId: ctx.userId,
})
if (!resolved.ok) {
return NextResponse.json(
{ error: 'Inte ansluten till Skatteverket.', code: 'NOT_CONNECTED' },
{ status: 401 }
)
}
const response = await skvRequestWithAuth(
resolved.auth,
'GET',
`/beslutat/${redovisare}/${redovisningsperiod}`
)
if (response.status === 404) {
return NextResponse.json({ data: null })
}
if (!response.ok) {
const text = await response.text()
return NextResponse.json(
{ error: `Skatteverket svarade med ${response.status}: ${text}` },
{ status: response.status }
)
}
const data = await response.json()
// A beslut means Skatteverket has processed the filing: confirm
// the period's moms deadline (terminal state).
if (data) {
await completeVatDeadlineFromRequest(request, ctx, 'confirmed')
}
return NextResponse.json({ data })
} catch (err) {
return handleSkvError(err)
}
},
},
// ══════════════════════════════════════════════════════════════
// 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: 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/submit',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
const blocked = await requireSkvCapability(ctx)
if (blocked) return blocked
try {
const { arbetsgivare, period, salaryRunId, xml } = await loadAGIXml(request, ctx)
console.log('[skatteverket] AGI submitting underlag:', { arbetsgivare, period })
const result = await agiPostUnderlag(ctx.supabase, ctx.userId, xml)
if (!result.ok) {
console.error('[skatteverket] AGI underlag error:', result.status, result.error)
return NextResponse.json(
{ error: result.error, code: result.body?.kod },
{ status: result.status },
)
}
await ctx.settings.set(
`agi_submission_${period}`,
JSON.stringify({
status: 'underlag_submitted',
arbetsgivare,
period,
salaryRunId,
inlamningId: result.data.inlamningId,
updatedAt: new Date().toISOString(),
}),
)
// 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) {
return handleSkvError(err)
}
},
},
// ── 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/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 inlamningId = Number(url.searchParams.get('inlamningId'))
if (!Number.isFinite(inlamningId) || inlamningId <= 0) {
return NextResponse.json({ error: 'Saknar parameter: inlamningId' }, { status: 400 })
}
const result = await agiGetKontrollresultat(ctx.supabase, ctx.userId, inlamningId)
if (!result.ok) {
return NextResponse.json(
{ error: result.error, code: result.body?.kod },
{ 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 })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── 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: 'POST',
path: '/agi/spara',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
const blocked = await requireSkvCapability(ctx)
if (blocked) return blocked
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.clear(`agi_submission_${period}`)
} 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 = Number(url.searchParams.get('inlamningId'))
if (!arbetsgivare || !period || !Number.isFinite(inlamningId) || inlamningId <= 0) {
return NextResponse.json(
{ error: 'Saknar parametrar: arbetsgivare, period, inlamningId' },
{ status: 400 },
)
}
const result = await agiTaBortSparadInlamning(
ctx.supabase, ctx.userId, arbetsgivare, period, inlamningId,
)
if (!result.ok) {
return NextResponse.json(
{ error: result.error, code: result.body?.kod },
{ status: result.status },
)
}
await ctx.settings.clear(`agi_submission_${period}`)
return NextResponse.json({ success: true })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── 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: '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 },
)
}
const result = await agiSkapaGranskningsunderlag(
ctx.supabase, ctx.userId, arbetsgivare, period, { lasPeriod },
)
if (!result.ok) {
return NextResponse.json(
{ 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: canSign ? 'awaiting_signing' : 'underlag_rejected',
arbetsgivare,
period,
signeringslank: result.data.link,
tillstand: result.data.tillstand,
meddelande: result.data.meddelande,
updatedAt: new Date().toISOString(),
}),
)
// Flip the matching agi_declarations row to pending_signature when
// SKV returns a usable granskningsunderlag. Previously this happened
// in /agi/spara, but /spara is only valid for re-saving rejected
// underlag: successful underlag are auto-persisted by SKV, so
// /spara returns felkod 20 on the happy path. Doing the flip here
// ensures the audit trail still moves through pending_signature
// → submitted (BFNAR 2013:2 kap 8 / BFL 5 kap 6§) without /spara.
if (canSign) {
const periodYear = parseInt(period.slice(0, 4))
const periodMonth = parseInt(period.slice(4, 6))
await ctx.supabase
.from('agi_declarations')
.update({ status: 'pending_signature' })
.eq('company_id', ctx.companyId)
.eq('period_year', periodYear)
.eq('period_month', periodMonth)
.in('status', ['generated', 'rejected'])
}
return NextResponse.json({ data: result.data })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── 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/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 },
)
}
const result = await agiGetKvittenser(
{ mode: 'user', supabase: ctx.supabase, userId: ctx.userId },
arbetsgivare,
period
)
if (!result.ok) {
return NextResponse.json(
{ error: result.error, code: result.body?.kod },
{ status: result.status },
)
}
// 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: kvittens.uuidKvittens,
signeradAv: kvittens.signeradAv,
signeradTid: kvittens.signeradTid,
updatedAt: new Date().toISOString(),
}),
)
// 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 })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── AGI: Pre-flight kontrollera HU/IU ───────────────────────────
// Validate a single HU or IU as JSON without saving anything in
// Skatteverket. Lets the UI catch errors per HU/IU before the user
// submits a full XML underlag. Body: the HU or IU as JSON per the
// v1.7 spec §7 / §8 (property names like agRegistreradId,
// redovisningsPeriod, kontantErsattningUlagAG, …).
//
// Response shape (kontrollsvar):
// { status: 'OK' | 'INFO' | 'ARENDE' | 'STOPP' | 'AVVISANDE',
// fel: [{ status, felmeddelande }, …] }
{
method: 'POST',
path: '/agi/kontrollera/hu',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
const blocked = await requireSkvCapability(ctx)
if (blocked) return blocked
const rbac = await requireAgiWriteRole(ctx)
if (rbac) return rbac
// Read body as bytes first so we can enforce a hard size cap before
// JSON.parse: protects against ~MB payloads that would otherwise
// hit the Zod validator.
const rawBytes = Buffer.byteLength(await request.clone().text(), 'utf8')
if (rawBytes > AGI_KONTROLLERA_MAX_BYTES) {
await writeSkatteverketAudit(ctx, {
endpoint: 'agi.kontrollera.hu',
outcome: 'validation_error',
responseStatus: 413,
requestSizeBytes: rawBytes,
errorMessage: 'payload exceeds 64KB cap',
})
return NextResponse.json(
{ error: 'Payload överstiger maxstorleken för pre-flight kontroll (64 KB).' },
{ status: 413 },
)
}
let parsedBody: unknown
try {
parsedBody = await request.json()
} catch {
await writeSkatteverketAudit(ctx, {
endpoint: 'agi.kontrollera.hu',
outcome: 'validation_error',
responseStatus: 400,
requestSizeBytes: rawBytes,
errorMessage: 'invalid JSON',
})
return NextResponse.json({ error: 'Ogiltig JSON i förfrågan.' }, { status: 400 })
}
const parsed = AGIKontrolleraHUSchema.safeParse(parsedBody)
if (!parsed.success) {
await writeSkatteverketAudit(ctx, {
endpoint: 'agi.kontrollera.hu',
outcome: 'validation_error',
responseStatus: 400,
requestSizeBytes: rawBytes,
errorMessage: parsed.error.issues
.map((iss) => `${iss.path.join('.')}: ${iss.message}`)
.join('; ')
.slice(0, 1000),
})
return NextResponse.json(
{
error: 'HU-payload matchar inte Skatteverkets v1.7 §7-schema.',
type: 'validation_error',
errors: parsed.error.issues.map((iss) => ({
field: iss.path.join('.'),
message: iss.message,
code: iss.code,
})),
},
{ status: 400 },
)
}
try {
const result = await agiKontrolleraHU(ctx.supabase, ctx.userId, parsed.data)
if (!result.ok) {
await writeSkatteverketAudit(ctx, {
endpoint: 'agi.kontrollera.hu',
agRegistreradId: parsed.data.agRegistreradId,
redovisningsperiod: parsed.data.redovisningsPeriod,
outcome: result.status === 401 || result.status === 403 ? 'auth_error' : 'skv_error',
responseStatus: result.status,
requestSizeBytes: rawBytes,
errorMessage: result.error,
})
return NextResponse.json(
{ error: result.error, code: result.body?.kod },
{ status: result.status },
)
}
await writeSkatteverketAudit(ctx, {
endpoint: 'agi.kontrollera.hu',
agRegistreradId: parsed.data.agRegistreradId,
redovisningsperiod: parsed.data.redovisningsPeriod,
outcome: 'ok',
responseStatus: result.status,
skvStatus: result.data?.status ?? null,
requestSizeBytes: rawBytes,
})
return NextResponse.json({ data: result.data })
} catch (err) {
await writeSkatteverketAudit(ctx, {
endpoint: 'agi.kontrollera.hu',
agRegistreradId: parsed.data.agRegistreradId,
redovisningsperiod: parsed.data.redovisningsPeriod,
outcome: 'internal_error',
requestSizeBytes: rawBytes,
errorMessage: err instanceof Error ? err.message : String(err),
})
return handleSkvError(err)
}
},
},
{
method: 'POST',
path: '/agi/kontrollera/iu',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
const blocked = await requireSkvCapability(ctx)
if (blocked) return blocked
const rbac = await requireAgiWriteRole(ctx)
if (rbac) return rbac
const rawBytes = Buffer.byteLength(await request.clone().text(), 'utf8')
if (rawBytes > AGI_KONTROLLERA_MAX_BYTES) {
await writeSkatteverketAudit(ctx, {
endpoint: 'agi.kontrollera.iu',
outcome: 'validation_error',
responseStatus: 413,
requestSizeBytes: rawBytes,
errorMessage: 'payload exceeds 64KB cap',
})
return NextResponse.json(
{ error: 'Payload överstiger maxstorleken för pre-flight kontroll (64 KB).' },
{ status: 413 },
)
}
let parsedBody: unknown
try {
parsedBody = await request.json()
} catch {
await writeSkatteverketAudit(ctx, {
endpoint: 'agi.kontrollera.iu',
outcome: 'validation_error',
responseStatus: 400,
requestSizeBytes: rawBytes,
errorMessage: 'invalid JSON',
})
return NextResponse.json({ error: 'Ogiltig JSON i förfrågan.' }, { status: 400 })
}
const parsed = AGIKontrolleraIUSchema.safeParse(parsedBody)
if (!parsed.success) {
await writeSkatteverketAudit(ctx, {
endpoint: 'agi.kontrollera.iu',
outcome: 'validation_error',
responseStatus: 400,
requestSizeBytes: rawBytes,
errorMessage: parsed.error.issues
.map((iss) => `${iss.path.join('.')}: ${iss.message}`)
.join('; ')
.slice(0, 1000),
})
return NextResponse.json(
{
error: 'IU-payload matchar inte Skatteverkets v1.7 §8-schema.',
type: 'validation_error',
errors: parsed.error.issues.map((iss) => ({
field: iss.path.join('.'),
message: iss.message,
code: iss.code,
})),
},
{ status: 400 },
)
}
try {
const result = await agiKontrolleraIU(ctx.supabase, ctx.userId, parsed.data)
if (!result.ok) {
await writeSkatteverketAudit(ctx, {
endpoint: 'agi.kontrollera.iu',
agRegistreradId: parsed.data.agRegistreradId,
redovisningsperiod: parsed.data.redovisningsPeriod,
outcome: result.status === 401 || result.status === 403 ? 'auth_error' : 'skv_error',
responseStatus: result.status,
requestSizeBytes: rawBytes,
errorMessage: result.error,
})
return NextResponse.json(
{ error: result.error, code: result.body?.kod },
{ status: result.status },
)
}
await writeSkatteverketAudit(ctx, {
endpoint: 'agi.kontrollera.iu',
agRegistreradId: parsed.data.agRegistreradId,
redovisningsperiod: parsed.data.redovisningsPeriod,
outcome: 'ok',
responseStatus: result.status,
skvStatus: result.data?.status ?? null,
requestSizeBytes: rawBytes,
})
return NextResponse.json({ data: result.data })
} catch (err) {
await writeSkatteverketAudit(ctx, {
endpoint: 'agi.kontrollera.iu',
agRegistreradId: parsed.data.agRegistreradId,
redovisningsperiod: parsed.data.redovisningsPeriod,
outcome: 'internal_error',
requestSizeBytes: rawBytes,
errorMessage: err instanceof Error ? err.message : String(err),
})
return handleSkvError(err)
}
},
},
// ── 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 })
const blocked = await requireSkvCapability(ctx)
if (blocked) return blocked
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 },
)
}
// Unlocking abandons the granskningsunderlag, so the locally-cached
// `awaiting_signing` record no longer reflects SKV: the signing link
// it carries points at a released draft. Clear it (mirroring the
// DELETE /agi/underlag and /agi/sparad handlers) so the panel drops
// back to the pre-submission state instead of stranding the user on a
// stale "redo att signeras" box.
await ctx.settings.clear(`agi_submission_${period}`)
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 })
const url = new URL(request.url)
const period = url.searchParams.get('period')
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 })
try {
return NextResponse.json({ data: JSON.parse(statusJson) })
} catch {
return NextResponse.json({ data: null })
}
},
},
// ══════════════════════════════════════════════════════════════
// Skattekonto routes (read-only balance + transactions)
// ══════════════════════════════════════════════════════════════
// ── Saldo (cached snapshot) ────────────────────────────────────
// Returns the most recent saldoResponse cached in extension_data.
// The dashboard uses this for repeated renders without hitting SKV.
// Force a refresh by calling POST /skattekonto/sync first.
{
method: 'GET',
path: '/skattekonto/saldo',
handler: async (_request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
const snapshot = await ctx.settings.get<SkattekontoBalanceSnapshot>(SKATTEKONTO_BALANCE_SNAPSHOT_KEY)
const lastSyncedAt = await ctx.settings.get<string>(SKATTEKONTO_LAST_SYNCED_AT_KEY)
return NextResponse.json({
data: snapshot?.saldo ?? null,
fetchedAt: snapshot ? new Date(snapshot.fetchedAt).toISOString() : null,
lastSyncedAt: lastSyncedAt ?? null,
})
},
},
// ── Transaktioner (from local table) ───────────────────────────
// Returns booked + upcoming transactions for the active company.
// Optional `from` query filters tidigare on transaktionsdatum >= from.
{
method: 'GET',
path: '/skattekonto/transaktioner',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
const url = new URL(request.url)
const from = url.searchParams.get('from')
let query = ctx.supabase
.from('skattekonto_transactions')
.select('*')
.eq('company_id', ctx.companyId)
.order('transaktionsdatum', { ascending: false })
if (from) query = query.gte('transaktionsdatum', from)
const { data, error } = await query
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
const rows = data ?? []
const today = new Date().toISOString().slice(0, 10)
const { booked, overdue, upcoming } = splitTransactions(rows, today)
// Enrich obokförda rader with a single-best-candidate suggestion.
// Only attached when there's exactly one match: avoids the UI
// confidently pointing at the wrong verifikat.
const suggestions = await findMatchSuggestionsBulk(
ctx.supabase,
ctx.companyId,
booked.map(r => ({
id: r.id,
transaktionsdatum: r.transaktionsdatum,
belopp_skatteverket: Number(r.belopp_skatteverket),
journal_entry_id: r.journal_entry_id,
})),
)
const bookedEnriched = booked.map(r => ({
...r,
match_suggestion: suggestions.get(r.id) ?? null,
}))
return NextResponse.json({
data: {
booked: bookedEnriched,
overdue,
upcoming,
},
})
},
},
// ── Manual sync ────────────────────────────────────────────────
// Pulls fresh saldo + transactions from Skatteverket and upserts.
{
method: 'POST',
path: '/skattekonto/sync',
handler: async (_request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
const blocked = await requireSkvCapability(ctx)
if (blocked) return blocked
try {
const result = await syncSkattekonto(ctx)
return NextResponse.json({ data: result })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── Bokför one row → draft journal entry ──────────────────────
// Creates a DRAFT verifikat in /bookkeeping for the user to review
// and commit. The skattekonto_transactions row is linked via
// journal_entry_id so the UI can show "Bokförd" status.
{
method: 'POST',
path: '/skattekonto/transaktioner/:id/bokfor',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
// Extract :id from the catch-all dispatcher's path-param convention
// (`_id` query string, set in app/api/extensions/ext/[...path]/route.ts).
const url = new URL(request.url)
const id = url.searchParams.get('_id')
if (!id) {
return NextResponse.json({ error: 'Saknar transaktions-id' }, { status: 400 })
}
try {
const entry = await bokforSkattekontoTransaction(
ctx.supabase,
ctx.companyId,
ctx.userId,
id,
)
return NextResponse.json({ data: { entry } })
} catch (err) {
if (err instanceof SkattekontoBookingError) {
const status =
err.code === 'TRANSACTION_NOT_FOUND' ? 404
: err.code === 'ALREADY_BOOKED' ? 409
: err.code === 'PERIOD_LOCKED' ? 423
: err.code === 'NO_COUNTER_ACCOUNT' ? 422
: 400
return NextResponse.json(
{ error: err.message, code: err.code },
{ status },
)
}
return handleSkvError(err)
}
},
},
// ── Matcha mot befintligt verifikat ──────────────────────────────
// List candidate journal entries already touching 1630 with the right
// amount/side near the transaction date. Lets the user link the SKV
// row to a manually-booked bank transfer instead of creating a duplicate
// verifikat. Returns at most 25 candidates.
{
method: 'GET',
path: '/skattekonto/transaktioner/:id/match-candidates',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
const url = new URL(request.url)
const id = url.searchParams.get('_id')
if (!id) {
return NextResponse.json({ error: 'Saknar transaktions-id' }, { status: 400 })
}
try {
const { candidates } = await findMatchCandidates(ctx.supabase, ctx.companyId, id)
return NextResponse.json({ data: { candidates } })
} catch (err) {
if (err instanceof SkattekontoMatchError) {
const status =
err.code === 'TRANSACTION_NOT_FOUND' ? 404
: err.code === 'ALREADY_BOOKED' ? 409
: 400
return NextResponse.json({ error: err.message, code: err.code }, { status })
}
return handleSkvError(err)
}
},
},
// Link the SKV row to a chosen candidate. No new verifikat is created:
// we just write journal_entry_id onto skattekonto_transactions. The
// candidate is re-validated server-side (matching 1630 line, not already
// linked) to catch races and a malicious client.
{
method: 'POST',
path: '/skattekonto/transaktioner/:id/match',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
const url = new URL(request.url)
const id = url.searchParams.get('_id')
if (!id) {
return NextResponse.json({ error: 'Saknar transaktions-id' }, { status: 400 })
}
let body: { journal_entry_id?: string }
try {
body = (await request.json()) as { journal_entry_id?: string }
} catch {
return NextResponse.json({ error: 'Ogiltig request body' }, { status: 400 })
}
if (!body.journal_entry_id || typeof body.journal_entry_id !== 'string') {
return NextResponse.json(
{ error: 'Saknar journal_entry_id' },
{ status: 400 },
)
}
try {
await matchSkattekontoToEntry(
ctx.supabase,
ctx.companyId,
id,
body.journal_entry_id,
)
return NextResponse.json({ data: { ok: true } })
} catch (err) {
if (err instanceof SkattekontoMatchError) {
const status =
err.code === 'TRANSACTION_NOT_FOUND' ? 404
: err.code === 'ENTRY_NOT_FOUND' ? 404
: err.code === 'ALREADY_BOOKED' ? 409
: err.code === 'ENTRY_ALREADY_LINKED' ? 409
: 422
return NextResponse.json({ error: err.message, code: err.code }, { status })
}
return handleSkvError(err)
}
},
},
],
eventHandlers: [
{
eventType: 'skattekonto.drift_detected',
handler: handleSkattekontoDriftDetected,
},
{
eventType: 'skattekonto.connection.expired',
handler: handleSkattekontoConnectionExpired,
},
],
// Registry-resolved commit services for the MCP submit tools. The core
// pending-operations dispatcher (lib/pending-operations/commit.ts) cannot
// import this extension (CI guard), so it reaches these through
// extensionRegistry.get('skatteverket')?.services when committing a staged
// submit_vat_declaration / submit_agi operation. Commit = "send for BankID
// signing" (returns a signing link), never "file".
services: {
commitSubmitVatDeclaration,
commitSubmitAgi,
},
}
// ── Helpers ───────────────────────────────────────────────────────────
/**
* Parse and validate declaration request body, then compute the momsuppgift.
*
* The computation itself lives in lib/declaration-prep.ts (buildMomsuppgift)
* so the commit-side service and MCP tools file exactly the same numbers this
* route does: see the no-drift note there. This shell only parses the body.
*/
async function parseDeclarationRequest(
request: Request,
ctx: ExtensionContext
): Promise<VatDeclarationPrep> {
const body = await request.json()
const { periodType, year, period, fiscalPeriodId } = body as {
periodType: VatPeriodType
year: number
period: number
fiscalPeriodId?: string
}
if (!periodType || !year || !period) {
throw new Error('Saknar obligatoriska fält: periodType, year, period')
}
return buildMomsuppgift(ctx.supabase, ctx.companyId, { periodType, year, period, fiscalPeriodId })
}
/**
* Parse redovisare and redovisningsperiod from query params.
* Used by GET/PUT/DELETE endpoints that don't need a full body.
*/
function parseQueryParams(
request: Request,
ctx: ExtensionContext
): { redovisare: string; redovisningsperiod: string } {
const url = new URL(request.url)
const redovisare = url.searchParams.get('redovisare')
const redovisningsperiod = url.searchParams.get('redovisningsperiod')
if (!redovisare || !redovisningsperiod) {
throw new Error('Saknar obligatoriska parametrar: redovisare, redovisningsperiod')
}
// Suppress unused variable warning: ctx is required by the type signature
void ctx
return { redovisare, redovisningsperiod }
}
/**
* Build the deadline generator's tax_period string (`YYYY-MM` monthly,
* `YYYY-QN` quarterly) from the picker params. Yearly periods use the
* fiscal-year label and need company settings; see yearlyVatTaxPeriod.
*/
function vatTaxPeriod(periodType: VatPeriodType, year: number, period: number): string | null {
if (periodType === 'monthly') return `${year}-${String(period).padStart(2, '0')}`
if (periodType === 'quarterly') return `${year}-Q${period}`
return null
}
/**
* The moms_yearly row's tax_period is the generator's fiscal-year label:
* `YYYY` for calendar fiscal years and `YYYY-1/YYYY` for broken ones (year
* = the FY-end year). Derived from company settings because the picker only
* carries the year.
*/
async function yearlyVatTaxPeriod(ctx: ExtensionContext, year: number): Promise<string> {
const { data } = await ctx.supabase
.from('company_settings')
.select('fiscal_year_start_month')
.eq('company_id', ctx.companyId)
.maybeSingle()
const startMonth = data?.fiscal_year_start_month ?? 1
return startMonth === 1 ? `${year}` : `${year - 1}/${year}`
}
/**
* Complete the moms deadline for the period identified by the request's
* optional periodType/year/period query params. Both monthly and quarterly
* types are passed for sub-annual periods: company settings decide which one
* exists, the other is a no-op. Best-effort by design (completeTaxDeadline
* never throws).
*/
async function completeVatDeadlineFromRequest(
request: Request,
ctx: ExtensionContext,
newStatus: 'submitted' | 'confirmed'
): Promise<void> {
const url = new URL(request.url)
const periodType = url.searchParams.get('periodType') as VatPeriodType | null
const year = Number(url.searchParams.get('year'))
const period = Number(url.searchParams.get('period'))
if (!periodType || !Number.isFinite(year) || !Number.isFinite(period) || !year || !period) {
return
}
const taxPeriod =
periodType === 'yearly'
? await yearlyVatTaxPeriod(ctx, year)
: vatTaxPeriod(periodType, year, period)
if (!taxPeriod) return
await completeTaxDeadline(
ctx.supabase,
ctx.companyId,
periodType === 'yearly' ? ['moms_yearly'] : ['moms_monthly', 'moms_quarterly'],
taxPeriod,
newStatus
)
}
/**
* 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).
*
* The lookup + status guard live in lib/declaration-prep.ts (buildAgiUnderlag)
* so the commit-side service files the same XML this route does. This shell
* only parses the salaryRunId from the body.
*/
async function loadAGIXml(
request: Request,
ctx: ExtensionContext,
): Promise<AgiUnderlagPrep> {
const body = (await request.json()) as { salaryRunId?: string }
return buildAgiUnderlag(ctx.supabase, ctx.companyId, body.salaryRunId ?? '')
}
/**
* Convert Skatteverket errors to appropriate HTTP responses.
*/
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
: err.code === 'TOKEN_REVOKED' ? 401
: 403
return NextResponse.json(
{ error: err.message, code: err.code },
{ status }
)
}
console.error('[skatteverket] API error:', err)
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Okänt fel' },
{ status: 500 }
)
}
// ── MCP submit commit services ─────────────────────────────────────────
//
// Registry-resolved by lib/pending-operations/commit.ts when a staged
// submit_vat_declaration / submit_agi op is approved. "Commit" runs the SKV
// chain up to the BankID signing link and returns it: the user's signature in
// the browser is the irreversible filing act, outside this code.
//
// Direct lib calls bypass the HTTP dispatcher's SKATTEVERKET_ENABLED gate
// (app/api/extensions/ext/[...path]/route.ts), so each service checks the flag
// itself and returns a recoverable EXTENSION_DISABLED result (the op stays
// reviewable). SkatteverketAuthError (no connection / scope / quota) is
// likewise recoverable. SKV business rejections are non-recoverable → the op
// is consumed and the user regenerates + re-stages.
function skatteverketEnabled(): boolean {
return process.env.SKATTEVERKET_ENABLED === 'true'
}
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
const EXTENSION_DISABLED_RESULT: Extract<SkvSubmitResult, { ok: false }> = {
ok: false,
code: 'EXTENSION_DISABLED',
http_status: 503,
recoverable: true,
error: 'Skatteverket-integrationen är inte aktiverad i denna miljö.',
}
/**
* Translate a thrown error inside a commit service to a SkvSubmitResult.
* SkatteverketAuthError (connection / scope / quota) is recoverable: the op
* stays reviewable so the user reconnects and re-approves. Anything else is a
* non-recoverable internal error → the op is rejected.
*/
async function mapServiceError(
ctx: ExtensionContext,
endpoint: string,
err: unknown,
): Promise<Extract<SkvSubmitResult, { ok: false }>> {
if (err instanceof SkatteverketAuthError) {
const mapped = skvAuthCodeToStructured(err.code)
await writeSkatteverketAudit(ctx, { endpoint, outcome: 'auth_error', errorMessage: err.message })
return { ok: false, code: mapped.code, http_status: mapped.httpStatus, recoverable: true, error: err.message }
}
await writeSkatteverketAudit(ctx, {
endpoint,
outcome: 'internal_error',
errorMessage: err instanceof Error ? err.message : String(err),
})
return {
ok: false,
code: 'SKATTEVERKET_INTERNAL_ERROR',
http_status: 500,
recoverable: false,
error: err instanceof Error ? err.message : 'Okänt fel',
}
}
/**
* VAT "skicka för signering": POST /utkast + PUT /las → signeringslänk.
* Recompute-at-commit (buildMomsuppgift over posted entries) so the figures
* filed equal what the preview showed for the same ledger state.
*/
async function commitSubmitVatDeclaration(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: Record<string, unknown>,
): Promise<SkvSubmitResult> {
if (!skatteverketEnabled()) return EXTENSION_DISABLED_RESULT
const periodType = params.period_type as VatPeriodType
const year = params.year as number
const period = params.period as number
const fiscalPeriodId = params.fiscal_period_id as string | undefined
const ctx = createExtensionContext(supabase, userId, companyId, 'skatteverket')
try {
// The staged figures were already reviewed at approval time, so the
// chain starts at the utkast write (no kontrollera pre-step here).
const result = await submitVatDeclarationChain(ctx, { periodType, year, period, fiscalPeriodId })
if (!result.ok) {
return {
ok: false, code: 'SKATTEVERKET_SUBMIT_REJECTED', http_status: result.httpStatus,
recoverable: false, error: result.error,
}
}
return {
ok: true,
signing_url: result.signingUrl,
redovisningsperiod: result.redovisningsperiod,
redovisare: result.redovisare,
kontrollresultat: result.kontrollresultat,
}
} catch (err) {
return mapServiceError(ctx, 'declaration/submit', err)
}
}
/**
* AGI "skicka för signering": POST /underlag → poll kontrollresultat →
* skapaGranskningsunderlag(lasPeriod) → Mina Sidor signing link. Mirrors the
* route handlers' status flips (monotonic guards) and audit rows.
*/
async function commitSubmitAgi(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: Record<string, unknown>,
): Promise<SkvSubmitResult> {
if (!skatteverketEnabled()) return EXTENSION_DISABLED_RESULT
const salaryRunId = params.salary_run_id as string
const ctx = createExtensionContext(supabase, userId, companyId, 'skatteverket')
try {
const { arbetsgivare, period, xml, periodYear, periodMonth } =
await buildAgiUnderlag(supabase, companyId, salaryRunId)
// 1. POST /underlag (XML) → inlamningId.
const submit = await agiPostUnderlag(supabase, userId, xml)
await writeSkatteverketAudit(ctx, {
endpoint: 'agi/submit', agRegistreradId: arbetsgivare, redovisningsperiod: period,
outcome: submit.ok ? 'ok' : 'skv_error', responseStatus: submit.status,
errorMessage: submit.ok ? null : submit.error,
})
if (!submit.ok) {
return { ok: false, code: 'SKATTEVERKET_SUBMIT_REJECTED', http_status: submit.status,
recoverable: false, error: submit.error }
}
const inlamningId = submit.data.inlamningId
await ctx.settings.set(`agi_submission_${period}`, JSON.stringify({
status: 'underlag_submitted', arbetsgivare, period, salaryRunId,
inlamningId, updatedAt: new Date().toISOString(),
}))
// 2. Poll kontrollresultat (bounded; SKV is typically sub-second).
let kontroll = await agiGetKontrollresultat(supabase, userId, inlamningId)
for (let i = 0; i < 2 && kontroll.ok && kontroll.data.status === 'PROCESSING'; i++) {
await sleep(750)
kontroll = await agiGetKontrollresultat(supabase, userId, inlamningId)
}
await writeSkatteverketAudit(ctx, {
endpoint: 'agi/kontrollresultat', agRegistreradId: arbetsgivare, redovisningsperiod: period,
outcome: kontroll.ok ? 'ok' : 'skv_error', responseStatus: kontroll.status,
skvStatus: kontroll.ok ? kontroll.data.status : null,
})
if (!kontroll.ok) {
return { ok: false, code: 'SKATTEVERKET_SUBMIT_REJECTED', http_status: kontroll.status,
recoverable: false, error: kontroll.error }
}
if (kontroll.data.status === 'PROCESSING') {
// Still processing after the bounded poll: recoverable; re-approve shortly.
return { ok: false, code: 'SKATTEVERKET_PROCESSING', http_status: 202, recoverable: true,
error: 'Skatteverket bearbetar fortfarande AGI-underlaget. Försök igen om en stund.' }
}
if (kontroll.data.status === 'DONE_REJECTED' || kontroll.data.status === 'DONE_FAILED') {
// Scope by salary_run_id, not just period: a correction run sharing the
// period must not have its (still-valid) declaration flipped to rejected.
await supabase.from('agi_declarations').update({ status: 'rejected' })
.eq('company_id', companyId).eq('salary_run_id', salaryRunId)
.eq('period_year', periodYear).eq('period_month', periodMonth)
.in('status', ['generated', 'pending_signature', 'exported'])
return { ok: false, code: 'AGI_KONTROLL_REJECTED', http_status: 422, recoverable: false,
error: 'Skatteverket avvisade AGI-underlaget vid kontroll. Åtgärda felen och generera om AGI:n.' }
}
// 3. skapaGranskningsunderlag (lasPeriod=true) → Mina Sidor signing link.
const gransk = await agiSkapaGranskningsunderlag(supabase, userId, arbetsgivare, period, { lasPeriod: true })
await writeSkatteverketAudit(ctx, {
endpoint: 'agi/granskningsunderlag', agRegistreradId: arbetsgivare, redovisningsperiod: period,
outcome: gransk.ok ? 'ok' : 'skv_error', responseStatus: gransk.status,
skvStatus: gransk.ok ? gransk.data.tillstand : null,
})
if (!gransk.ok) {
return { ok: false, code: 'SKATTEVERKET_SUBMIT_REJECTED', http_status: gransk.status,
recoverable: false, error: gransk.error }
}
const tillstand = gransk.data.tillstand
const canSign = tillstand === 'LOCKED_FOR_SIGNING' || tillstand === 'UNLOCKED'
await ctx.settings.set(`agi_submission_${period}`, JSON.stringify({
status: canSign ? 'awaiting_signing' : 'underlag_rejected', arbetsgivare, period,
signeringslank: gransk.data.link, tillstand, meddelande: gransk.data.meddelande,
updatedAt: new Date().toISOString(),
}))
if (tillstand === 'INCORRECT_DATA') {
return { ok: false, code: 'AGI_GRANSKNING_INCORRECT', http_status: 422, recoverable: false,
error: gransk.data.meddelande || 'Skatteverket avvisade granskningsunderlaget. Åtgärda felen och generera om AGI:n.' }
}
if (!canSign) {
// RECEIVING / CALCULATING etc.: still processing, recoverable.
return { ok: false, code: 'SKATTEVERKET_PROCESSING', http_status: 202, recoverable: true,
error: gransk.data.meddelande || 'Skatteverket bearbetar fortfarande underlaget. Försök igen om en stund.' }
}
// Flip the declaration to pending_signature (monotonic guard). Scoped by
// salary_run_id so a correction run in the same period isn't co-flipped:
// more precise than the period-only route handler, which has no run id.
await supabase.from('agi_declarations').update({ status: 'pending_signature' })
.eq('company_id', companyId).eq('salary_run_id', salaryRunId)
.eq('period_year', periodYear).eq('period_month', periodMonth)
.in('status', ['generated', 'rejected'])
return {
ok: true,
signing_url: gransk.data.link,
arbetsgivare,
period,
inlamning_id: inlamningId,
tillstand,
}
} catch (err) {
return mapServiceError(ctx, 'agi/submit', err)
}
}