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