* fix(bankid): harden login/signup flow — polling, signup rollback, metadata merge, enrichment lookup - middleware: read BankID enrichment from the bankid_enrichment table (the extension_data path has been dead since the multi-tenant refactor), so company-less BankID users land on /select-company instead of the manual wizard - BankIdAuth: hard 6-min poll deadline; every failed poll counts toward the give-up limit; guard overlapping ticks so completion runs exactly once (a double /complete regenerated the magic link and invalidated the first, failing logins intermittently); retry clicks wait out the start cooldown instead of silently no-oping; Swedish messages for 429/unknown start errors - bankid/complete: all-or-nothing signup — delete the created user when the identity insert, app_metadata update, or magic-link generation fails, so a retry starts clean instead of hitting account_exists with an unusable account - bankid/unlink: read-merge-write app_metadata so has_password survives unlink (BankID-only users could otherwise strand themselves with no login method) - login: BankID "create account" CTA now links to /register instead of dismissing the notice; sv.json: fix missing å/ä/ö in settings_bankid strings Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: move secondary guides into docs/, delete dead root files Move DOCKER.md, SELF-HOSTING.md, WHITELABEL.md and extensions.md (renamed EXTENSIONS.md) into a new docs/ folder and update all path references (README, setup.sh, .dockerignore image rules, docker-publish workflow comment, _example-branding, lib/branding/service.ts). Delete two dead root files: customer.json (stray API-test payload) and findings.md (point-in-time swarm audit export, criticals already filed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Emil <emilmattsson14@gmail.com> * fix(api): security & correctness hardening + withRouteContext MFA migration across API routes Audit of ~100 app/api routes. Highlights: Security - agent/conversations: list leaked colleagues' titles + message previews (company-scoped RLS, no user filter) -> user-scoped - calendar/feed PUT: raw body into .update() allowed feed_token fixation on a public unauthenticated URL -> strict schema, content toggles only - bokslutsdispositioner: unbounded schablonintaktRate could inflate the IL 30 kap 25% periodiseringsfond cap base -> bounded - agent profile/composer/onboarding: viewers could rewrite the agent profile while sibling /verify blocked them -> role-gated Correctness - account-totals / listAssets: unbounded queries silently truncated at 1000 rows (under-counted money; skipped assets at year-end depreciation) -> fetchAllRows with stable order (+3 more pagination fixes) - voucher-gaps: swallowed detect_voucher_gaps RPC errors (BFNAR gap view could show "no gaps" when the check never ran) -> surfaced - 5 phantom-success writes (OK on zero matched rows) fixed - assets K3 component-sum validated against stale acquisition_cost -> fixed - invite silent email-send failure -> response carries email_sent; deadlines/calendar cast-then-check JSON crashes -> Zod Convention - ~44 legacy routes converted to withRouteContext (MFA); added Zod validation, corrected status codes, console.* -> lib/logger Response shapes preserved for existing callers. ~110 new tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): save a booking as a reusable template from Bokför direkt Add a "Spara som mall" action to the manual booking dialog so users can capture a kontering they just worked out as a booking template — right where they figured out how something should be booked. - derive amount-parameterised template lines from the concrete booking (settlement = the non-VAT leg nearest the total, 26xx = a VAT line with its rate snapped to the nearest standard rate, the rest = business ratios; line labels come from the loaded BAS chart) - extract the shared TemplateForm out of BookingTemplatesPanel so the booking dialog reuses the same editor, live preview and convertibility hints instead of duplicating them - save via the existing POST /api/settings/booking-templates endpoint Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bokslut): render arsredovisning RR/BR at ÅRL post level — no kontonummer Bolagsverket rejected a user's filed årsredovisning with "Balansräkning och resultaträkning ska inte innehålla kontonummer": the PDF built every statement row as per-account "1930 Företagskonto" lines while the iXBRL filing path already aggregated to statutory posts, so the two artifacts diverged. The PDF statements now derive from the same K2 risbs mapping the iXBRL document uses (mapTrialBalancesToK2), via a new statement-rows.ts that emits post-level rows in uppställningsform order for both the K2 and K3 templates. Also fixed along the way: - Jämförelseår column (ÅRL 3:5 §) — previous-year trial balances now load and render; the old PDF had no comparatives at all. - mapping.warnings (unmapped accounts, RR ≠ 2099, obalans, reclass nudges) flow into ArsredovisningData.warnings so the wizard flags a non-fileable document before download. - Flerårsöversikt current/previous year overridden with the mapper's strict-3000–3799 Nettoomsattning, mirroring build-input's duplicate-fact rule, so the FB table ties to the RR. - FB eget kapital-table is post-level and drops obeskattade reserver (never eget kapital); K3 equity-changes statement uses real prior-year opening balances with derived utdelning/nyemission residuals that tie the roll-forward exactly to booked UB. - build-input dedupes warnings now that the PDF path runs the same mapping. Regression test asserts no RR/BR label ever contains a four-digit account number again. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reports): diagnose untransferred prior-year results behind balance-sheet differens Prod incident (97 kr): a multi-year SIE migration lacked one year's omforing av arets resultat; the residual corrupted every later derived opening balance and Balansrakningen showed a bare "Differens: 97 kr" with no explanation. Continuity checking cannot catch this failure mode (prior-year UB and derived IB match per-account by construction) - the invariant that actually breaks is per-year P&L = 0 for all non-latest years. - lib/reports/imbalance-diagnosis.ts: shared detector (findUntransferredResults + buildImbalanceDiagnosis) - Balansrakning/Balansrapport attach imbalance_diagnosis when unbalanced, naming the exact culprit years; rendered in web views + PDF; MCP gnubok_get_balance_sheet inherits the field via spread - SIE import: parse-time warning when a completed year's vouchers leave a P&L residual, plus a post-import DB walk surfacing culprits as warnings and structured details.untransferredResults; the Arcim migration workspace previously dropped result.warnings entirely and now renders them - opening-balance/correct: pre-flight the company lock date and return 409 OB_COMPANY_LOCK_DATE (retryable: false, lock date interpolated in the client message) instead of the retryable 500 that invited blind retries; catch-path maps a raced trigger rejection to the same code Diagnosis runs only on unbalanced paths (zero cost when healthy) and never fails the report or the import. No migration, nothing persisted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: production error remediation — FX rates, deadlines, log levels, correction relink Batch of fixes for recurring Vercel runtime errors: - Riksbanken FX rates: persistent read-through cache (exchange_rates table), one retry honoring Retry-After on 429/5xx, bounded ingest concurrency, and an honest fallback — most recent cached observation or null, never a hardcoded rate silently booked into amount_sek. Unrated transactions stay repairable via refresh-exchange-rate. - Tax deadline regeneration inserts replacement rows before deleting the superseded set, so a failed insert no longer wipes a company's deadlines (the 23502 user_id regression did exactly that). Migration makes deadlines.user_id nullable for system-generated rows. - Route wrappers + errorResponse log 4xx outcomes at warn so only genuine 5xx reach Vercel's runtime-error clustering; client-supplied /api/log telemetry demoted to warn as well. - application/json documents (raw PSD2 responses archived per BFL) validate as parseable JSON with object/array root instead of always failing the magic-byte check. - correctEntry surfaces document-relink failures to callers, and the BFL document-immutability trigger now allows relinking underlag from a reversed entry to its correction (migration + pg test). - Middleware clears stale session cookies on /api requests too, using scope 'local' so cleanup doesn't re-trigger the failed token refresh. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(skatteverket): persist token health and stop retrying dead consents Terminal auth errors (SESSION_EXPIRED, REFRESH_EXHAUSTED, MISSING_SCOPE, TOKEN_CORRUPTED) mark the token row needs_reconsent with the error code and timestamp — SKV per-flow refresh tokens live 65 minutes, so once expired nothing recovers without a fresh BankID consent. The AGI kvittens and skattekonto sync crons skip flagged connections instead of failing every night, and the settings panel prompts for re-consent proactively. A successful reconnect resets the row to active. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(banking): allocate distinct BAS ledger slots for PSD2 mirror accounts A bank returning N same-currency accounts used to map them all onto the currency default (1930/1932/1933/1934), tripping the UNIQUE (company_id, ledger_account) constraint per-account — swallowed errors left accounts silently unmirrored. allocatePsd2LedgerAccount now hands out the currency default first, then free 1931–1959 sub-account slots, skipping slots held by any existing row. - Callback persists allocations to accounts_data so the picker pre-fills reality; reconnect reuses previously mirrored ledgers instead of re-deriving (a user remap to 1935 survives). - Selection save resolves effective ledgers up front and rejects duplicates or cross-connection conflicts with a 400 instead of silently skipping the mirror. - Bank error codes + psu_type are forwarded to the settings page for every OAuth error, keying the Handelsbanken corporate fullmakt guidance. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(agent): stage exact journal lines on categorization previews Categorization previews only carried debit/credit accounts, the GROSS amount, and separate VAT rows — read together that looks like an unbalanced 'gross on cost account + VAT debit' entry, and it misled both users and agents into rejecting correct proposals. The MCP preview and the pending-operation PATCH now materialize the exact lines the commit executor will post (net cost line, VAT line, gross bank line, SEK) via buildTransactionEntryLines, and PATCH re-derives them from the new mapping instead of spreading stale staged lines. ApprovalCard and /pending render the verifikat lines, falling back to the legacy summary only for operations staged before this fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(bookkeeping): prune unused imported accounts from the chart SIE imports routinely bring in hundreds of accounts that were never used and clutter the kontoplan. New account_usage_counts RPC (one grouped query instead of a count per account) backs GET /api/bookkeeping/accounts/usage, and POST /api/bookkeeping/accounts/prune deletes zero-usage accounts — dry-run first, then an explicit account list capped at 2000. Accounts with journal lines are skipped, never deleted. The chart manager shows a usage column and a prune dialog grouping custom accounts vs unused BAS-seeded ones. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(api): carry dimensions through v1 invoice and supplier-invoice surfaces Credit-note creation now copies default_dimensions and per-line dimensions from the original, so the reversing journal entry nets against the same dimension cells instead of dropping them. List/detail responses expose the dimension fields, and the OpenAPI spec snapshot follows. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf: batch serial Supabase round-trips on hot dashboard paths Every dashboard render pays the layout's query chain, so serialized awaits are direct wall-clock: the layout, chat conversation, invoice detail, supplier detail, select-company, and agent-onboarding pages now run their independent lookups in parallel batches, and getCompanyCapabilities folds its disabled-config read into the same round-trip. JournalEntryList hydrates the saved fiscal-year scope optimistically instead of serializing the first entries fetch behind the fiscal-periods request. The supplier detail page filters invoices server-side via a new supplier_id query param instead of fetching the whole company ledger, and the invoice editor (with its framer-motion dependency) lazy-loads so it stops shipping with the invoice list bundle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(salary): one-click runs, payslip delivery, payments settings, run cockpit Salary P1 batch, driving the 20-click flow toward 3 clicks: - One-click 'Starta lönekörning': POST /api/salary/runs accepts an empty body and resolves defaults server-side — period follows the latest non-corrected run, payment date from the new salary_pay_day setting, series from the per-source-type map. The separate /salary/runs/new page is gone. - Run detail page rebuilt as a step-railed cockpit (progress rail, KPI cards, employee ledger, journal preview) on a deliberately wider canvas; components extracted to components/salary/run/. - Payslip delivery: tokenized public payslip pages (/payslip/[token], backed by salary_payslip_links) plus per-employee email send with PDF — employees need no account, and the middleware exempts the route from auth redirects. - Payments settings: salary pay day, default bank, and pain.001 vs Bankgirot Lön format with per-bank upload instructions and an LB sunset warning (banks retire LB during 2026). - AGI panel: full submission status flows (stale drafts, signing links, kvittens polling, error reports); tax payment panel with skattekonto shortcut and mark-as-paid. - Salary calendar bulk editing, employee benefits/tax-card polish, municipality tax-table lookup improvements. messages/sv+en also carry the strings for the account-prune, skatteverket-reconsent, and banking surfaces committed just before this. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: adopt Next 16 proxy.ts convention + repo housekeeping - Rename middleware.ts to proxy.ts with the proxy() export (Next 16 renamed the middleware convention; behavior unchanged). - Exclude dev_docs/ from tsconfig so stray snippets in planning docs don't break the build type-check. - Ratchet antipatterns-baseline down (raw-route-auth 165 → 119) to lock in the withRouteContext migration from 5cfd2b76. - template-library uses roundOre() instead of inline rounding. - database.md: drop account_balances from the key-tables list. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bookkeeping): robust service-role detection in correction document relink relink_documents_to_correction() keyed its service-role branch on auth.role(), which reads the singular request.jwt.claim.role GUC that PostgREST v10+ and the pg-real harness no longer populate. Genuine service-role callers (pending-ops executor / MCP approve) landed in the auth gate and could not relink underlag. Read the role from the request.jwt.claims JSON directly, mirroring the canonical link_voucher_rpcs_tenant_guard convention. Validated on staging. Also: harden the salary run page's error paths (res.json().catch) against non-JSON error bodies, and roll back the pg-real service-role case in finally so an aborted transaction cannot poison a pooled connection for the next test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(documents): restore journal_entry_line_id link durability (BFL 7 kap) Migration 20260704103000 rewrote enforce_document_journal_entry_immutability to guard journal_entry_id but left journal_entry_line_id to the metadata trigger, which exempts draft-linked docs -- and the entry-level trigger only fired on UPDATE OF journal_entry_id, so a line-id-only UPDATE never invoked it at all. That let a set journal_entry_line_id be cleared to NULL, breaking the "link durable from first set" invariant (document-immutability.pg regression). Widen the trigger to fire on journal_entry_line_id too and guard it with the same uuid-durability rule as journal_entry_id (setting NULL -> uuid stays allowed; clearing/re-pointing a set value is blocked, status-independent). The correction-relink GUC path, which legitimately clears line_id when moving underlag to the posted correction, stays exempt. Validated on staging. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Signed-off-by: Emil <emilmattsson14@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2244 lines
91 KiB
TypeScript
2244 lines
91 KiB
TypeScript
import crypto from 'crypto'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
|
|
import { NextResponse } 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 { buildAuthorizeUrl, exchangeCodeForTokens, generatePkcePair } from './lib/oauth'
|
|
import { storeTokens, getTokens, deleteTokens, getTokenHealth } from './lib/token-store'
|
|
import { skvRequest, 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 { 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 { bokforSkattekontoTransaction, SkattekontoBookingError } from './lib/skattekonto-booking'
|
|
import { handleSkattekontoDriftDetected } from './lib/skattekonto-drift-email'
|
|
import {
|
|
findMatchCandidates,
|
|
findMatchSuggestionsBulk,
|
|
matchSkattekontoToEntry,
|
|
SkattekontoMatchError,
|
|
} from './lib/skattekonto-match'
|
|
import { splitTransactions } from './lib/skattekonto-buckets'
|
|
import type { SkattekontoBalanceSnapshot, SkatteverketUtkastResponse } from './types'
|
|
import type { VatPeriodType } from '@/types'
|
|
|
|
/**
|
|
* 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://oauth2.skatteverket.se/oauth2
|
|
* 8. Verify Sentry 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.
|
|
*
|
|
* 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'])
|
|
|
|
/**
|
|
* 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
|
|
}
|
|
|
|
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 state = crypto.randomUUID()
|
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
|
|
const redirectUri = `${appUrl}/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
|
|
await ctx.settings.set('oauth_state', state)
|
|
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')
|
|
|
|
// JSON-encode for safe embedding inside <script>. Escapes quotes and `</`
|
|
// so the value can't break out of the script tag.
|
|
const jsLiteral = (value: unknown) =>
|
|
JSON.stringify(value ?? '').replace(/</g, '\\u003c')
|
|
|
|
// 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).
|
|
const respondWithSuccess = (fallbackPath: string) => {
|
|
const html = `<!DOCTYPE html><html><body><script>
|
|
if (window.opener) {
|
|
window.opener.postMessage({ type: 'skatteverket-oauth-success' }, ${jsLiteral(appUrl)});
|
|
window.close();
|
|
} else {
|
|
window.location.href = ${jsLiteral(`${appUrl}${fallbackPath}`)};
|
|
}
|
|
</script><p>Anslutningen lyckades. Du kan stänga denna flik.</p></body></html>`
|
|
return new Response(html, {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
})
|
|
}
|
|
|
|
const respondWithError = (reason: string, fallbackPath: string) => {
|
|
const escapedReason = reason
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
const html = `<!DOCTYPE html><html><body><script>
|
|
if (window.opener) {
|
|
window.opener.postMessage({ type: 'skatteverket-oauth-error', reason: ${jsLiteral(reason)} }, ${jsLiteral(appUrl)});
|
|
window.close();
|
|
} else {
|
|
window.location.href = ${jsLiteral(`${appUrl}${fallbackPath}`)};
|
|
}
|
|
</script><p>Anslutningen misslyckades: ${escapedReason}</p></body></html>`
|
|
return new Response(html, {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
})
|
|
}
|
|
|
|
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')}`,
|
|
)
|
|
}
|
|
|
|
// Exchange code FIRST: 5-minute expiry, do this before anything else
|
|
const { createClient } = await import('@/lib/supabase/server')
|
|
const { requireCompanyId } = await import('@/lib/company/context')
|
|
const supabase = await createClient()
|
|
|
|
// Verify user session (browser should still have cookies)
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
if (!user) {
|
|
// Login redirects always go to a full page: popups can't render the
|
|
// login form usefully, so this is the one path that keeps a hard
|
|
// redirect even from inside the popup.
|
|
return NextResponse.redirect(
|
|
`${appUrl}/login?redirect=${encodeURIComponent('/reports?tab=vat-declaration')}`
|
|
)
|
|
}
|
|
|
|
// Resolve the active company: state/redirect_uri were stored keyed on company_id
|
|
// by ctx.settings.set() in the /authorize handler.
|
|
let companyId: string
|
|
try {
|
|
companyId = await requireCompanyId(supabase, user.id)
|
|
} catch {
|
|
return respondWithError(
|
|
'Inget företag valt',
|
|
`/reports?tab=vat-declaration&skv_error=${encodeURIComponent('Inget företag valt')}`,
|
|
)
|
|
}
|
|
|
|
// Validate CSRF state
|
|
const { data: settingsData } = await supabase
|
|
.from('extension_data')
|
|
.select('value')
|
|
.eq('company_id', companyId)
|
|
.eq('extension_id', 'skatteverket')
|
|
.eq('key', 'oauth_state')
|
|
.single()
|
|
|
|
if (!settingsData || settingsData.value !== state) {
|
|
return respondWithError(
|
|
'Ogiltig state-parameter (CSRF)',
|
|
`/reports?tab=vat-declaration&skv_error=${encodeURIComponent('Ogiltig state-parameter (CSRF)')}`,
|
|
)
|
|
}
|
|
|
|
// Get the stored redirect URI
|
|
const { data: redirectData } = await supabase
|
|
.from('extension_data')
|
|
.select('value')
|
|
.eq('company_id', companyId)
|
|
.eq('extension_id', 'skatteverket')
|
|
.eq('key', 'oauth_redirect_uri')
|
|
.single()
|
|
|
|
const redirectUri = redirectData?.value ||
|
|
`${appUrl}/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 { data: verifierData } = await supabase
|
|
.from('extension_data')
|
|
.select('value')
|
|
.eq('company_id', companyId)
|
|
.eq('extension_id', 'skatteverket')
|
|
.eq('key', 'oauth_code_verifier')
|
|
.maybeSingle()
|
|
|
|
const codeVerifier = (verifierData?.value as string | null) || undefined
|
|
|
|
// Optional in-app destination set by /authorize?return_to=...
|
|
const { data: returnToData } = await supabase
|
|
.from('extension_data')
|
|
.select('value')
|
|
.eq('company_id', companyId)
|
|
.eq('extension_id', 'skatteverket')
|
|
.eq('key', 'oauth_return_to')
|
|
.maybeSingle()
|
|
|
|
const returnTo = (returnToData?.value as string | null) || null
|
|
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(supabase, user.id, tokens, companyId)
|
|
|
|
// Clean up CSRF state + the one-shot return_to + PKCE verifier.
|
|
await supabase
|
|
.from('extension_data')
|
|
.delete()
|
|
.eq('company_id', companyId)
|
|
.eq('extension_id', 'skatteverket')
|
|
.in('key', ['oauth_state', 'oauth_return_to', 'oauth_code_verifier'])
|
|
|
|
return respondWithSuccess(successPath)
|
|
} catch (err) {
|
|
console.error('[skatteverket] Token exchange failed:', err)
|
|
// 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 })
|
|
},
|
|
},
|
|
|
|
// ── 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 })
|
|
}
|
|
|
|
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 })
|
|
}
|
|
|
|
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 })
|
|
}
|
|
|
|
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)
|
|
}
|
|
},
|
|
},
|
|
|
|
// ── 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)
|
|
|
|
const response = await skvRequest(
|
|
ctx.supabase,
|
|
ctx.userId,
|
|
'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()
|
|
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 response = await skvRequest(
|
|
ctx.supabase,
|
|
ctx.userId,
|
|
'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()
|
|
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 })
|
|
}
|
|
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 })
|
|
}
|
|
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(ctx.supabase, 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 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 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 })
|
|
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 })
|
|
}
|
|
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,
|
|
},
|
|
],
|
|
|
|
// 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 } = body as {
|
|
periodType: VatPeriodType
|
|
year: number
|
|
period: number
|
|
}
|
|
|
|
if (!periodType || !year || !period) {
|
|
throw new Error('Saknar obligatoriska fält: periodType, year, period')
|
|
}
|
|
|
|
return buildMomsuppgift(ctx.supabase, ctx.companyId, { periodType, year, period })
|
|
}
|
|
|
|
/**
|
|
* 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 }
|
|
}
|
|
|
|
/**
|
|
* 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 ctx = createExtensionContext(supabase, userId, companyId, 'skatteverket')
|
|
|
|
try {
|
|
const { redovisare, redovisningsperiod, momsuppgift } =
|
|
await buildMomsuppgift(supabase, companyId, { periodType, year, period })
|
|
|
|
// 1. POST /utkast: save the draft to Eget utrymme. Overwrites any prior
|
|
// draft for the period, so retry after a mid-chain failure is safe.
|
|
const utkast = await skvRequest(
|
|
supabase, userId, 'POST', `/utkast/${redovisare}/${redovisningsperiod}`, momsuppgift,
|
|
)
|
|
await writeSkatteverketAudit(ctx, {
|
|
endpoint: 'declaration/draft', agRegistreradId: redovisare, redovisningsperiod,
|
|
outcome: utkast.ok ? 'ok' : 'skv_error', responseStatus: utkast.status,
|
|
})
|
|
if (!utkast.ok) {
|
|
const text = await utkast.text().catch(() => '')
|
|
return {
|
|
ok: false, code: 'SKATTEVERKET_SUBMIT_REJECTED', http_status: utkast.status,
|
|
recoverable: false, error: `Skatteverket svarade med ${utkast.status}: ${text}`,
|
|
}
|
|
}
|
|
const utkastData = (await utkast.json()) as SkatteverketUtkastResponse
|
|
|
|
// 2. PUT /las: lock for signing; returns the BankID signeringslänk.
|
|
const las = await skvRequest(
|
|
supabase, userId, 'PUT', `/las/${redovisare}/${redovisningsperiod}`,
|
|
)
|
|
await writeSkatteverketAudit(ctx, {
|
|
endpoint: 'declaration/lock', agRegistreradId: redovisare, redovisningsperiod,
|
|
outcome: las.ok ? 'ok' : 'skv_error', responseStatus: las.status,
|
|
})
|
|
if (!las.ok) {
|
|
const text = await las.text().catch(() => '')
|
|
return {
|
|
ok: false, code: 'SKATTEVERKET_SUBMIT_REJECTED', http_status: las.status,
|
|
recoverable: false, error: `Skatteverket svarade med ${las.status}: ${text}`,
|
|
}
|
|
}
|
|
const lasData = (await las.json()) as SkatteverketUtkastResponse
|
|
if (!lasData.signeringsLank) {
|
|
return {
|
|
ok: false, code: 'SKATTEVERKET_SUBMIT_REJECTED', http_status: 502, recoverable: false,
|
|
error: 'Skatteverket låste deklarationen men returnerade ingen signeringslänk.',
|
|
}
|
|
}
|
|
|
|
// Persist locked state so the UI/poller can resume (mirrors /declaration/lock).
|
|
await ctx.settings.set(
|
|
`submission_${redovisningsperiod}`,
|
|
JSON.stringify({
|
|
status: 'draft_locked', redovisare, redovisningsperiod,
|
|
signeringsLank: lasData.signeringsLank, updatedAt: new Date().toISOString(),
|
|
}),
|
|
)
|
|
|
|
return {
|
|
ok: true,
|
|
signing_url: lasData.signeringsLank,
|
|
redovisningsperiod,
|
|
redovisare,
|
|
kontrollresultat: utkastData.kontrollResultat ?? null,
|
|
}
|
|
} 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)
|
|
}
|
|
}
|