* feat(company): ideell förening as a third legal form, behind a flag (#2072 step 1) Why the problem occurred: the legal form was modelled as a binary flag in ~300 files. `EntityType` was a two-member union, but nothing dispatched on it exhaustively: 28 sites defaulted `?? 'enskild_firma'` (invoice, categorize, match, stripe, invoice-inbox) or `?? 'aktiebolag'` (year-end, bokslut, MCP), and every form-dependent choice was an `=== 'aktiebolag' ? A : B` ternary. Widening the union compiled everywhere and changed nothing, so a förening would have booked as an enskild firma in the app and as an aktiebolag in bokslut and MCP, with no error anywhere. The lookup refused föreningar at the door (mapEntityType returned null), which is what the tester hit. What was removed or simplified: the silent defaults. One module, lib/company/entity-type.ts, now holds the list (ENTITY_TYPES), the parser (never defaults), the resolver (settings hint, then companies.entity_type, then throw) and `byEntityType`, whose Record arms make the compiler refuse the next widening until each site has an answer. The form-dependent facts (closing account, owner settlement account, calendar-year lock, default method, K1/K2 label, personnummer vs 16-prefix) live there once instead of in the ternaries. On the SQL side supported_entity_types() replaces four copies of the literal list in the create RPCs. Why this shape and not the proposed one: the tracker asked for the enum widening plus a chart; that alone was the dangerous version (compiles, books wrong). Bundling stiftelse was considered and dropped: identical plumbing but no chart block. Creation sits behind NEXT_PUBLIC_IDEELL_FORENING_ENABLED so the CHECK, RPCs and seed can ship now and the first partner is switched on without a migration; the flag goes when Phase 2 (packs, INK3, årsbokslut, Swish) lands on the tracker. Domain choices (DECISIONS.md 2026-09-08, verify with an accountant before Phase 2): result closes to 2069 with 2068 as prior-year carry; no owner accounts, member settlement on 2890; accrual default; brutet räkenskapsår allowed; K1 label for the 5 000 kr accrual threshold (BFNAR 2010:1); org number gets the 16 prefix. Migration 20260908110835 widens the three CHECK constraints, adds supported_entity_types(), re-creates the three create RPCs with the widened guard and adds the förening block to seed_chart_of_accounts. Applied to staging and covered by ideell-forening-entity-type.pg.test.ts. Part of #2072 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh * fix(company): close the förening paths the skeptic refuted (#2072) Five refutations from the /skeptic pass on 7a05c54d2, each fixed at the shared definition rather than the reported site: 1. Privately paid supplier invoices and the utlägg dialog resolved the owner account in lib/expenses/payer.ts with its own AB/EF ternary, so a förening member's invoice was built on 2893 and then refused by the expense-claim service (which already said 2890), burning an ankomstnummer. The helper now uses ownerSettlementAccount. 2. Booking templates substitute their `_ab` accounts only for an aktiebolag; the `private_expense` template kept its base 2013 for a förening. Template accounts now resolve through templateAccountForForm: EF base, AB override, förening base with owner accounts translated to 2890 (booking-templates.ts and proposal-lines.ts share it). 3. A VAT-registered förening with helårsmoms got no momsdeklaration deadline: the annual VAT rule bailed on anything but AB/EF. A förening is a juridisk person and follows the räkenskapsår schedule (SFL 26 kap 33 §), so the rule now keys on fiscalYearLockedToCalendar instead of the two literals; same in the MCP VAT report. 4. 2069 would have accumulated across years: the year-open omföring was AB-only with 2099/2098 hard-coded. planResultAppropriation now takes the pair from resultClosingAccounts (AB 2099 -> 2098, förening 2069 -> 2068) and skips forms with no carry (EF). 5. With the flag off, a registry lookup that returned "Ideell förening" was prefilled into the onboarding journey, the form picker was skipped and the create step answered "Ogiltig företagsform" with no way back. The journey, the BankID picker, the onboarding page and the MCP lookup now use mapSetupEntityType, which maps only creatable forms, so a flagged-off form falls through to the picker as before. Also: form picker keeps its AB-first order; tests for each fix. Part of #2072 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh * chore(migrations): move ideell förening migration after main's latest version (20260908143051) Two migrations landed on main after the branch forked; a lower version would be skipped by the merge-time apply. Staging history row renamed to match. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh * chore(skills): regenerate accounted-api reference for the widened entity_type enum Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
3240 lines
136 KiB
TypeScript
3240 lines
136 KiB
TypeScript
import { sleep } from '@/lib/utils'
|
|
import crypto from 'crypto'
|
|
import { z } from 'zod'
|
|
import { parseEntityType } from '@/lib/company/entity-type'
|
|
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 { skatteverketConnectorMode, startConnectorAuthorization } from './lib/connector-mode'
|
|
import { isConnectorState, verifyConnectorState } from '@/lib/connect/hosted/state'
|
|
import {
|
|
requireFlowInitiator,
|
|
FLOW_INITIATOR_MISMATCH_MESSAGE,
|
|
} from '@/lib/auth/oauth-flow-binding'
|
|
import {
|
|
consumeOAuthFlowHandoff,
|
|
consumeOAuthFlowState,
|
|
createOAuthFlow,
|
|
mintOAuthFlowHandoff,
|
|
newOAuthFlowId,
|
|
peekOAuthFlowHandoff,
|
|
peekOAuthFlowState,
|
|
purgeExpiredOAuthFlows,
|
|
requestMatchesOrigin,
|
|
resolveOAuthOrigin,
|
|
type OAuthFlow,
|
|
} from '@/lib/auth/oauth-flows'
|
|
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,
|
|
isOrgNumberContested,
|
|
markConnectionRevoked,
|
|
recordProbeResult,
|
|
} from './lib/connection-store'
|
|
import { currentSkvEnvironment, resolveReadAuth } from './lib/resolve-auth'
|
|
import { probeCompanyGrants } from './lib/grant-probe'
|
|
import { formatRedovisare } from '@/lib/skatteverket/format'
|
|
import { isSkvSessionRefreshable } from '@/lib/skatteverket/session-lifetime'
|
|
import { createUtseOmbudDeepLink, OMBUD_ROLE_KEYS, OmbudApiError } from './lib/ombud-client'
|
|
import { createExtensionContext } from '@/lib/extensions/context-factory'
|
|
import type {
|
|
SkvSubmitResult,
|
|
SkattekontoBookCommitResult,
|
|
} 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 { fetchVatDeclarationStatus } from './lib/declaration-status'
|
|
import { runPostConnectRefresh } from './lib/post-connect-refresh'
|
|
import { readAgiSubmissionStatus } from './lib/agi-submission-status'
|
|
import {
|
|
attachBookingSuggestions,
|
|
bokforSkattekontoTransaction,
|
|
bokforSkattekontoTransactionsBatch,
|
|
SkattekontoBookingError,
|
|
} from './lib/skattekonto-booking'
|
|
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')
|
|
|
|
// Every state or handoff failure on the unauthenticated callback gets this
|
|
// one message: unknown, forged, expired, replayed, wrong origin. Telling the
|
|
// caller which one would make the route an oracle for live flows.
|
|
const SKV_STATE_REJECTED_MESSAGE =
|
|
'Anslutningen kunde inte verifieras (ogiltig eller förbrukad state). Stäng fliken och försök ansluta igen.'
|
|
|
|
// Answered only when the session vanished between the identity check and
|
|
// the consume (a race, not the normal path: a session-less arrival is sent
|
|
// to login BEFORE the flow is spent and resumes from there).
|
|
const SKV_SESSION_MISSING_MESSAGE =
|
|
'Sessionen har gått ut. Stäng fliken, logga in och försök ansluta igen.'
|
|
|
|
// 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. First live call against Ombudshantering v2 in the test service
|
|
* (scope `obr` was added to application id arcimtechnologyab_gnubok_1
|
|
* on 2026-09-01): confirm the list envelope, read the rollbeteckning
|
|
* codes from GET /roller and pin them in SKATTEVERKET_OMBUD_ROLL_LASOMBUD
|
|
* / SKATTEVERKET_OMBUD_ROLL_MOMS (lib/ombud-client.ts). Grant
|
|
* verification then runs on the register; the read-service probes in
|
|
* grant-probe.ts stay as the fallback and still need their 403 bodies
|
|
* validated (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 daily ombud
|
|
* sync cron (/api/extensions/skatteverket/ombud/sync/cron) runs from
|
|
* shadow mode on: it only records grant state.
|
|
*
|
|
* 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)
|
|
}
|
|
|
|
/**
|
|
* The ombud path binds Skatteverket system-credential access to an org
|
|
* number, and org numbers are public and tenant-editable. While more than
|
|
* one live company claims the same one, no company may verify a grant or
|
|
* mint a deep link on it: a tenant that typed a victim's number must never
|
|
* inherit the victim's grant. Returns the 409 to send, or null when clear.
|
|
*/
|
|
async function contestedOrgNumberResponse(orgNumber: string): Promise<NextResponse | null> {
|
|
if (!(await isOrgNumberContested(orgNumber))) return null
|
|
return NextResponse.json(
|
|
{
|
|
error:
|
|
'Organisationsnumret används av fler än ett företag i Accounted. ' +
|
|
'Kontakta support för att aktivera ombudsanslutningen.',
|
|
code: 'ORG_NUMBER_CONTESTED',
|
|
},
|
|
{ status: 409 }
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Resolve auth for a company-scoped READ triggered from the UI: the caller's
|
|
* own token if they connected, otherwise any active token another member of
|
|
* the company connected (see resolve-auth.ts, #1673). Membership is the only
|
|
* gate: ctx.companyId is resolved from the caller's own memberships and the
|
|
* token table's SELECT policy is company-scoped, so this can never reach
|
|
* another company's row.
|
|
*/
|
|
function resolveCompanyReadAuth(ctx: ExtensionContext) {
|
|
return resolveReadAuth(ctx.supabase, ctx.companyId, { requires: 'lasombud', userId: ctx.userId })
|
|
}
|
|
|
|
/**
|
|
* The 401 a read route answers when no usable company token exists.
|
|
* NOT_CONNECTED keeps the "inte anslutet" empty state (no row at all);
|
|
* SESSION_EXPIRED mirrors what the token refresh would have thrown for a row
|
|
* already flagged needs_reconsent, so the UI shows its reconnect prompt
|
|
* instead of pretending the company never connected.
|
|
*/
|
|
function readAuthFailureResponse(reason: 'no_token' | 'needs_reconsent'): NextResponse {
|
|
if (reason === 'needs_reconsent') {
|
|
return NextResponse.json(
|
|
{
|
|
error:
|
|
'Anslutningen mot Skatteverket har gått ut. Skatteverkets inloggning gäller bara ca 1 timme, så detta är normalt. Anslut igen med BankID.',
|
|
code: 'SESSION_EXPIRED',
|
|
},
|
|
{ status: 401 },
|
|
)
|
|
}
|
|
return NextResponse.json(
|
|
{ error: 'Inte ansluten till Skatteverket.', code: 'NOT_CONNECTED' },
|
|
{ status: 401 },
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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. The flow is recorded as one oauth_flows row keyed by
|
|
// the state (lib/auth/oauth-flows.ts): who started it, on which origin,
|
|
// and what the token exchange must repeat.
|
|
{
|
|
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 = newOAuthFlowId()
|
|
let 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
|
|
|
|
// The origin the user is on (app or a validated white-label brand
|
|
// domain). The callback finishes the flow there: that is where the
|
|
// opener tab lives and where the session cookies are.
|
|
const origin = await resolveOAuthOrigin(request)
|
|
|
|
// 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()
|
|
|
|
// Connector mode (self-host with a connector key and no own SKV
|
|
// client): the broker builds the authorize URL against Arcim's
|
|
// registered SKV client and OUR hosted redirect_uri; the hosted
|
|
// callback bounces the browser back to THIS instance's /callback with
|
|
// the code. The broker's redirect_uri replaces the local one (the
|
|
// token exchange must repeat the redirect_uri SKV saw), and the
|
|
// broker's signed connector_state is persisted for that exchange.
|
|
const connector = skatteverketConnectorMode()
|
|
let authorizeUrl: string
|
|
let connectorState: string | null = null
|
|
if (connector) {
|
|
try {
|
|
const started = await startConnectorAuthorization(connector, {
|
|
companyRef: ctx.companyId,
|
|
returnUrl: redirectUri,
|
|
state,
|
|
codeChallenge: pkce.challenge,
|
|
})
|
|
redirectUri = started.redirectUri
|
|
connectorState = started.connectorState
|
|
authorizeUrl = started.authorizeUrl
|
|
} catch (err) {
|
|
log.error('connector authorize-url failed', err as Error, { companyId: ctx.companyId })
|
|
return NextResponse.json(
|
|
{
|
|
error:
|
|
'Kunde inte starta Skatteverket-anslutningen via connectorn. ' +
|
|
'Kontrollera GNUBOK_CONNECTOR_KEY och anslutningsläget på /api/connector/status.',
|
|
},
|
|
{ status: 502 },
|
|
)
|
|
}
|
|
} else {
|
|
authorizeUrl = buildAuthorizeUrl(redirectUri, state, {
|
|
codeChallenge: pkce.challenge,
|
|
})
|
|
}
|
|
|
|
const { createServiceClient } = await import('@/lib/supabase/server')
|
|
const db = createServiceClient()
|
|
// Expired rows go here rather than in a cron: the set is tiny and
|
|
// every connect attempt is a fine moment to sweep it. Best-effort.
|
|
try {
|
|
await purgeExpiredOAuthFlows(db)
|
|
} catch (err) {
|
|
log.warn('oauth flow purge failed', { error: (err as Error).message })
|
|
}
|
|
await createOAuthFlow(db, {
|
|
id: state,
|
|
kind: 'skatteverket',
|
|
companyId: ctx.companyId,
|
|
userId: ctx.userId,
|
|
origin,
|
|
redirectUri,
|
|
codeVerifier: pkce.verifier,
|
|
connectorState,
|
|
returnTo,
|
|
})
|
|
|
|
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. The flow is
|
|
// resolved from the oauth_flows row the state names, and the completion
|
|
// is bound to that row's user via the session on the initiating origin.
|
|
{
|
|
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)
|
|
let code = url.searchParams.get('code')
|
|
const state = url.searchParams.get('state')
|
|
const error = url.searchParams.get('error')
|
|
const handoffId = url.searchParams.get('handoff')
|
|
// Where the opener tab lives. The canonical app origin until the flow
|
|
// row says otherwise; never derived from the request itself.
|
|
let responseOrigin = new URL(appUrl).origin
|
|
|
|
// Connector branch: a self-hosted instance started this SKV consent
|
|
// through the /api/connect/skv broker, which registered OUR redirect
|
|
// uri and a signed connector state. We never exchange the code here
|
|
// (the instance does, through the broker's /oauth/token): just bounce
|
|
// the browser back to the instance with the code + its original
|
|
// state, so no per-instance redirect uri is registered at SKV.
|
|
if (!handoffId && isConnectorState(state)) {
|
|
const verified = verifyConnectorState(state as string)
|
|
if (!verified.ok || verified.payload.svc !== 'skv') {
|
|
return NextResponse.redirect(`${appUrl}/?connector_error=${encodeURIComponent(verified.ok ? 'wrong_service' : verified.reason)}`)
|
|
}
|
|
const ret = new URL(verified.payload.ret)
|
|
if (error) ret.searchParams.set('error', error)
|
|
const errorDescription = url.searchParams.get('error_description')
|
|
if (errorDescription) ret.searchParams.set('error_description', errorDescription)
|
|
if (code) ret.searchParams.set('code', code)
|
|
if (verified.payload.st) ret.searchParams.set('state', verified.payload.st)
|
|
ret.searchParams.set('connector_state', state as string)
|
|
return NextResponse.redirect(ret.toString())
|
|
}
|
|
|
|
// Injection-safety invariants: responseOrigin is either deployment
|
|
// configuration (NEXT_PUBLIC_APP_URL) or a server-validated origin
|
|
// written by /authorize, never callback 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',
|
|
'Referrer-Policy': 'no-referrer',
|
|
})
|
|
|
|
// 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). Both go to the
|
|
// origin the flow started on. 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 state 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(responseOrigin)});
|
|
window.close();
|
|
} else {
|
|
window.location.replace(${jsLiteral(`${responseOrigin}${fallbackPath}`)});
|
|
}
|
|
</script><p>Anslutningen lyckades. Du kan stänga denna flik.</p></body></html>`
|
|
return new Response(html, {
|
|
status: 200,
|
|
headers: responseHeaders(nonce),
|
|
})
|
|
}
|
|
|
|
// Once the flow row is known the message goes to the opener's own
|
|
// origin and is guaranteed to arrive, so the tab stays open on error:
|
|
// the reason stays on screen and cannot become an invisible "nothing
|
|
// happens". Before the row is known (unknown, expired or replayed
|
|
// state or handoff) the target is a guess, and a brand opener would
|
|
// never hear it: then the tab closes so the panels' closed-tab
|
|
// watcher resets them instead of leaving Connect disabled forever.
|
|
const respondWithError = (
|
|
reason: string,
|
|
fallbackPath: string,
|
|
options: { closeTab?: boolean } = {},
|
|
) => {
|
|
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(responseOrigin)});
|
|
${options.closeTab ? 'window.close();' : ''}
|
|
} else {
|
|
window.location.replace(${jsLiteral(`${responseOrigin}${fallbackPath}`)});
|
|
}
|
|
</script><p>Anslutningen misslyckades: ${escapedReason}</p><p>Du kan stänga denna flik.</p></body></html>`
|
|
return new Response(html, {
|
|
status: 200,
|
|
headers: responseHeaders(nonce),
|
|
})
|
|
}
|
|
|
|
const defaultErrorPath = (msg: string) =>
|
|
`/reports?tab=vat-declaration&skv_error=${encodeURIComponent(msg)}`
|
|
|
|
if (!handoffId && ((!code && !error) || !state)) {
|
|
return respondWithError('Saknar auktoriseringskod', defaultErrorPath('Saknar auktoriseringskod'), { closeTab: true })
|
|
}
|
|
|
|
const { createServiceClient } = await import('@/lib/supabase/server')
|
|
const db = createServiceClient()
|
|
|
|
// Resolve the flow. Hop 2 arrives with a handoff id; hop 1 (or the
|
|
// only hop, when the callback host is the app host) with the
|
|
// provider's state. Both consumes are atomic and single-use, and
|
|
// every failure (unknown, forged, expired, replayed, wrong origin) is
|
|
// the same answer: this route is unauthenticated and must not be an
|
|
// oracle. The handoff is claimed for the validated origin this
|
|
// request arrived on: decided by host with the scheme from
|
|
// configuration, so a proxy that drops x-forwarded-proto cannot make
|
|
// hop 2 disagree with what /authorize recorded.
|
|
//
|
|
// On the hop that finishes the flow (it has the initiating origin's
|
|
// cookies), the completing browser is bound to the initiator BEFORE
|
|
// the one-shot row is spent: the row names who started the flow, and
|
|
// the tokens below are stored for that user with the service client.
|
|
// A session-less arrival goes to login and resumes into this very
|
|
// URL; a different user is refused and the row stays claimable for
|
|
// the initiator; only then is the row consumed. A victim lured into
|
|
// approving a consent someone else started thus never has their
|
|
// BankID-authorised access stored under that someone, and nobody
|
|
// can burn a live flow by merely reaching its URL signed out.
|
|
//
|
|
// Membership is checked here as well, before the consume: the
|
|
// service-role token write below has no RLS backstop, membership
|
|
// can be revoked between /authorize and this callback (#1091), and
|
|
// a revoked initiator must not burn the one-shot provider code on
|
|
// the way to being refused.
|
|
const bindInitiator = async (identity: { userId: string; companyId: string; origin: string }) => {
|
|
responseOrigin = identity.origin
|
|
const initiator = await requireFlowInitiator(request, identity.userId, {
|
|
flow: 'skatteverket.callback',
|
|
returnOrigin: identity.origin,
|
|
})
|
|
if (!initiator.ok) {
|
|
if (initiator.reason === 'no_session') return { userId: null, response: initiator.response }
|
|
return {
|
|
userId: null,
|
|
response: respondWithError(
|
|
FLOW_INITIATOR_MISMATCH_MESSAGE,
|
|
defaultErrorPath(FLOW_INITIATOR_MISMATCH_MESSAGE),
|
|
),
|
|
}
|
|
}
|
|
const { data: membership } = await db
|
|
.from('company_members')
|
|
.select('user_id')
|
|
.eq('company_id', identity.companyId)
|
|
.eq('user_id', initiator.userId)
|
|
.maybeSingle()
|
|
if (!membership) {
|
|
return {
|
|
userId: null,
|
|
response: respondWithError(
|
|
'Behörighet saknas för företaget',
|
|
defaultErrorPath('Behörighet saknas för företaget'),
|
|
),
|
|
}
|
|
}
|
|
return { userId: initiator.userId, response: null }
|
|
}
|
|
|
|
let flow: OAuthFlow
|
|
let providerError: string | null = null
|
|
let boundUserId: string | null = null
|
|
if (handoffId) {
|
|
const arrivedOn = await resolveOAuthOrigin(request)
|
|
const identity = await peekOAuthFlowHandoff(db, handoffId, arrivedOn, 'skatteverket')
|
|
if (!identity) {
|
|
return respondWithError(SKV_STATE_REJECTED_MESSAGE, defaultErrorPath(SKV_STATE_REJECTED_MESSAGE), { closeTab: true })
|
|
}
|
|
const bound = await bindInitiator(identity)
|
|
if (bound.response) return bound.response
|
|
boundUserId = bound.userId
|
|
const handoff = await consumeOAuthFlowHandoff(db, handoffId, arrivedOn, 'skatteverket')
|
|
if (!handoff) {
|
|
return respondWithError(SKV_STATE_REJECTED_MESSAGE, defaultErrorPath(SKV_STATE_REJECTED_MESSAGE), { closeTab: true })
|
|
}
|
|
flow = handoff
|
|
code = handoff.providerCode
|
|
providerError = handoff.providerError
|
|
} else {
|
|
const identity = await peekOAuthFlowState(db, state as string, 'skatteverket')
|
|
if (!identity) {
|
|
return respondWithError(SKV_STATE_REJECTED_MESSAGE, defaultErrorPath(SKV_STATE_REJECTED_MESSAGE), { closeTab: true })
|
|
}
|
|
// Single hop (the callback host is the initiating host): bind here.
|
|
// Hop 1 on the registered host has no session to bind; hop 2 does.
|
|
if (requestMatchesOrigin(request, identity.origin)) {
|
|
const bound = await bindInitiator(identity)
|
|
if (bound.response) return bound.response
|
|
boundUserId = bound.userId
|
|
}
|
|
const consumed = await consumeOAuthFlowState(db, state as string, 'skatteverket')
|
|
if (!consumed) {
|
|
return respondWithError(SKV_STATE_REJECTED_MESSAGE, defaultErrorPath(SKV_STATE_REJECTED_MESSAGE), { closeTab: true })
|
|
}
|
|
flow = consumed
|
|
if (error) providerError = url.searchParams.get('error_description') || 'Okänt fel'
|
|
}
|
|
responseOrigin = flow.origin
|
|
|
|
const successPath = flow.returnTo
|
|
? `${flow.returnTo}${flow.returnTo.includes('?') ? '&' : '?'}skv_connected=true`
|
|
: `/reports?tab=vat-declaration&skv_connected=true`
|
|
const errorPath = (msg: string) =>
|
|
flow.returnTo
|
|
? `${flow.returnTo}${flow.returnTo.includes('?') ? '&' : '?'}skv_error=${encodeURIComponent(msg)}`
|
|
: defaultErrorPath(msg)
|
|
|
|
// Hop 1 on the registered callback host: no session for the
|
|
// initiating origin can exist here. Stash the provider's result on
|
|
// the row (encrypted, under a separate handoff id) and send the
|
|
// browser to the origin the flow started on. Provider credentials
|
|
// never enter this URL. Compared by host only: see requestHost.
|
|
if (!handoffId && !requestMatchesOrigin(request, flow.origin)) {
|
|
let nextHandoff: string
|
|
try {
|
|
nextHandoff = await mintOAuthFlowHandoff(
|
|
db,
|
|
flow,
|
|
providerError !== null ? { providerError } : { providerCode: code as string },
|
|
)
|
|
} catch (err) {
|
|
// The state is spent; the user restarts. The response must still
|
|
// be the callback page, so the opener hears it and resets.
|
|
log.error('oauth handoff mint failed', err as Error, { companyId: flow.companyId })
|
|
return respondWithError(
|
|
'Ett tekniskt fel uppstod. Försök igen.',
|
|
errorPath('Ett tekniskt fel uppstod'),
|
|
)
|
|
}
|
|
const target = new URL('/api/extensions/ext/skatteverket/callback', flow.origin)
|
|
target.searchParams.set('handoff', nextHandoff)
|
|
return new Response(null, {
|
|
status: 302,
|
|
headers: {
|
|
Location: target.toString(),
|
|
'Cache-Control': 'no-store',
|
|
'Referrer-Policy': 'no-referrer',
|
|
},
|
|
})
|
|
}
|
|
|
|
// Bound above, before the consume. Re-checked here only because the
|
|
// row was read twice: the session that passed the identity check is
|
|
// the one that must own the tokens.
|
|
if (!boundUserId || boundUserId !== flow.userId) {
|
|
log.error('oauth callback bound user diverged from the consumed flow', {
|
|
companyId: flow.companyId,
|
|
})
|
|
return respondWithError(SKV_SESSION_MISSING_MESSAGE, errorPath(SKV_SESSION_MISSING_MESSAGE))
|
|
}
|
|
const userId = boundUserId
|
|
const companyId = flow.companyId
|
|
|
|
if (providerError !== null) {
|
|
return respondWithError(providerError, errorPath(providerError))
|
|
}
|
|
if (!code) {
|
|
return respondWithError(SKV_STATE_REJECTED_MESSAGE, errorPath(SKV_STATE_REJECTED_MESSAGE))
|
|
}
|
|
|
|
try {
|
|
// The exchange must repeat the redirect_uri SKV saw and the PKCE
|
|
// verifier /authorize generated; both come from the row.
|
|
const tokens = await exchangeCodeForTokens(
|
|
code,
|
|
flow.redirectUri,
|
|
flow.codeVerifier ?? undefined,
|
|
flow.connectorState ?? undefined,
|
|
)
|
|
await storeTokens(db, userId, tokens, companyId)
|
|
|
|
// 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)
|
|
// 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()
|
|
// Honest refreshability: SKV's per-flow refresh token dies 65 minutes
|
|
// after issue, five minutes past access-token expiry. A stored
|
|
// refresh token past that window is not "can refresh"; reporting it
|
|
// as such kept the reconnect banner silent for days.
|
|
const canRefresh = isSkvSessionRefreshable({
|
|
expiresAt: tokens.expires_at,
|
|
hasRefreshToken: tokens.refresh_token !== null,
|
|
refreshCount: tokens.refresh_count,
|
|
})
|
|
|
|
// 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,
|
|
parseEntityType(settings.entity_type)
|
|
)
|
|
|
|
const contestedForVerify = await contestedOrgNumberResponse(orgNumber)
|
|
if (contestedForVerify) return contestedForVerify
|
|
|
|
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: deep link to appoint Accounted as ombud ──
|
|
// Ombudshantering v2 mints a link into Skatteverket's e-service with
|
|
// Accounted pre-filled as ombud and both roles pre-selected; the company
|
|
// only signs with BankID there. Runs on the system identity (the link's
|
|
// ombud is whoever holds the token), so it needs the same configuration
|
|
// as verify.
|
|
{
|
|
method: 'POST',
|
|
path: '/system-connection/deeplink',
|
|
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 }
|
|
)
|
|
}
|
|
|
|
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,
|
|
parseEntityType(settings.entity_type)
|
|
)
|
|
|
|
const contestedForLink = await contestedOrgNumberResponse(orgNumber)
|
|
if (contestedForLink) return contestedForLink
|
|
|
|
try {
|
|
const link = await createUtseOmbudDeepLink(orgNumber, OMBUD_ROLE_KEYS)
|
|
// Minting the link is the tenant's opt-in: record a pending row
|
|
// (no grant state yet) so the nightly ombud sync, which only ever
|
|
// touches existing rows, picks the signed grant up on its own.
|
|
const optIn = await recordProbeResult({
|
|
companyId: ctx.companyId,
|
|
environment: currentSkvEnvironment(),
|
|
orgNumber,
|
|
createdBy: ctx.userId,
|
|
error: null,
|
|
})
|
|
if (!optIn) {
|
|
// Handing out the link without the row would let the company
|
|
// sign at Skatteverket and never be picked up by the sync.
|
|
log.error('deep link minted but opt-in row could not be stored', { companyId: ctx.companyId })
|
|
return NextResponse.json(
|
|
{ error: 'Anslutningen kunde inte sparas. Försök igen.' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
await writeSkatteverketAudit(ctx, {
|
|
endpoint: 'system-connection/deeplink',
|
|
agRegistreradId: orgNumber,
|
|
outcome: 'ok',
|
|
})
|
|
return NextResponse.json({
|
|
data: {
|
|
djuplank: link.djuplank,
|
|
roller: link.roller,
|
|
expires_on: link.expiresOn,
|
|
},
|
|
})
|
|
} catch (err) {
|
|
if (err instanceof OmbudApiError) {
|
|
log.warn('ombud deep link failed', { companyId: ctx.companyId, code: err.code, message: err.message })
|
|
return NextResponse.json({ error: err.message, code: err.code }, { status: 502 })
|
|
}
|
|
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}`
|
|
)
|
|
|
|
await writeSkatteverketAudit(ctx, {
|
|
endpoint: 'declaration/lock',
|
|
agRegistreradId: redovisare,
|
|
redovisningsperiod,
|
|
outcome: response.ok ? 'ok' : 'skv_error',
|
|
responseStatus: response.status,
|
|
})
|
|
|
|
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, and
|
|
// any member may read what another member's token fetches (#1673).
|
|
const resolved = await resolveReadAuth(ctx.supabase, ctx.companyId, {
|
|
requires: 'moms_ombud',
|
|
userId: ctx.userId,
|
|
})
|
|
if (!resolved.ok) return readAuthFailureResponse(resolved.reason)
|
|
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 readAuthFailureResponse(resolved.reason)
|
|
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). When the kvittens reconciliation (cron or
|
|
// post-connect refresh) has already promoted the declaration it deletes
|
|
// that cache, so the receipt is served from agi_declarations instead:
|
|
// kvittensnummer, signeradAv and signeradTid must be visible regardless
|
|
// of which path fetched them (#1597). 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}`)
|
|
const data = await readAgiSubmissionStatus(ctx.supabase, ctx.companyId, period, statusJson)
|
|
return NextResponse.json({ data })
|
|
},
|
|
},
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// 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. 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.
|
|
//
|
|
// "Connected" means the COMPANY has a token row (any member's) or a
|
|
// verified system grant, not that the caller personally pressed
|
|
// Anslut: the snapshot and the transactions belong to the company
|
|
// (#1673). A row already flagged needs_reconsent still counts as
|
|
// connected here so the stale snapshot stays visible; the sync route
|
|
// is what surfaces the reconnect prompt.
|
|
const resolved = await resolveCompanyReadAuth(ctx)
|
|
if (!resolved.ok && resolved.reason === 'no_token') {
|
|
return readAuthFailureResponse('no_token')
|
|
}
|
|
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.
|
|
// Ignored rows (is_ignored) are excluded from the work buckets and only
|
|
// reported as `ignored_count`; pass `include_ignored=1` to also get the
|
|
// rows themselves (the "Ignorerade" band). Never silently dropped.
|
|
{
|
|
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')
|
|
const includeIgnoredParam = url.searchParams.get('include_ignored')
|
|
const includeIgnored =
|
|
includeIgnoredParam === '1' || includeIgnoredParam === 'true'
|
|
|
|
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, ignored } = 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,
|
|
ignored_count: ignored.length,
|
|
...(includeIgnored ? { ignored } : {}),
|
|
},
|
|
})
|
|
},
|
|
},
|
|
|
|
// ── 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 {
|
|
// Any member may trigger a sync on the company's connection: the
|
|
// resolved auth carries the token OWNER's userId, so the refresh
|
|
// writes back to their row, never to the caller's (#1673). A row
|
|
// flagged needs_reconsent cannot heal on its own, so answer the
|
|
// reconnect prompt directly instead of burning a refresh call.
|
|
const resolved = await resolveCompanyReadAuth(ctx)
|
|
if (!resolved.ok) return readAuthFailureResponse(resolved.reason)
|
|
const result = await syncSkattekonto(ctx, resolved.auth)
|
|
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 === 'ROW_IGNORED' ? 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 === 'ROW_IGNORED' ? 409
|
|
: err.code === 'ENTRY_ALREADY_LINKED' ? 409
|
|
: 422
|
|
return NextResponse.json({ error: err.message, code: err.code }, { status })
|
|
}
|
|
return handleSkvError(err)
|
|
}
|
|
},
|
|
},
|
|
|
|
// ── Ignorera / återställ en rad ───────────────────────────────────
|
|
// Skattekonto rows mirror Skatteverket's ledger and are never deleted;
|
|
// is_ignored is the sanctioned way to take an unbookable row off the
|
|
// work list (e.g. an EF row predating the first räkenskapsår). Fully
|
|
// reversible: PATCH { is_ignored: false } restores it. A booked row
|
|
// cannot be ignored (409; the DB CHECK enforces the same invariant).
|
|
{
|
|
method: 'PATCH',
|
|
path: '/skattekonto/transaktioner/:id/ignore',
|
|
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: { is_ignored?: unknown }
|
|
try {
|
|
body = (await request.json()) as { is_ignored?: unknown }
|
|
} catch {
|
|
return NextResponse.json({ error: 'Ogiltig request body' }, { status: 400 })
|
|
}
|
|
if (typeof body.is_ignored !== 'boolean') {
|
|
return NextResponse.json(
|
|
{ error: 'is_ignored måste vara true eller false' },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
const isIgnored = body.is_ignored
|
|
|
|
const { data: tx, error: txError } = await ctx.supabase
|
|
.from('skattekonto_transactions')
|
|
.select('id, journal_entry_id, is_ignored')
|
|
.eq('id', id)
|
|
.eq('company_id', ctx.companyId)
|
|
.single()
|
|
if (txError || !tx) {
|
|
return NextResponse.json(
|
|
{ error: 'Skattekonto-transaktionen hittades inte.' },
|
|
{ status: 404 },
|
|
)
|
|
}
|
|
if (isIgnored && tx.journal_entry_id) {
|
|
return NextResponse.json(
|
|
{ error: 'Transaktionen är redan bokförd och kan inte ignoreras.' },
|
|
{ status: 409 },
|
|
)
|
|
}
|
|
|
|
// Conditional write: `.is('journal_entry_id', null)` on the ignore
|
|
// path makes a concurrent booking race lose cleanly (zero rows
|
|
// updated -> 409) instead of tripping the DB CHECK.
|
|
let update = ctx.supabase
|
|
.from('skattekonto_transactions')
|
|
.update({ is_ignored: isIgnored })
|
|
.eq('id', id)
|
|
.eq('company_id', ctx.companyId)
|
|
if (isIgnored) {
|
|
update = update.is('journal_entry_id', null)
|
|
}
|
|
const { data: updated, error: updateError } = await update.select('id')
|
|
if (updateError) {
|
|
return NextResponse.json({ error: updateError.message }, { status: 500 })
|
|
}
|
|
if (!updated || updated.length === 0) {
|
|
return NextResponse.json(
|
|
{ error: 'Transaktionen är redan bokförd och kan inte ignoreras.' },
|
|
{ status: 409 },
|
|
)
|
|
}
|
|
|
|
return NextResponse.json({ data: { ok: true, is_ignored: isIgnored } })
|
|
},
|
|
},
|
|
],
|
|
|
|
// No email consumers. skattekonto.connection.expired is still emitted
|
|
// (needs_reconsent flagging, UI banner, agent briefing) but a per-episode
|
|
// expiry mail is one mail per connect with SKV's 65-minute sessions, which
|
|
// trains users to ignore it (DECISIONS.md 2026-08-25). The skattekonto
|
|
// drift mail was removed 2026-09-02: the reconciliation page and the Hem
|
|
// notice (lib/notices/categories.ts detectSkvUnexplained) are the surface,
|
|
// and the mail alerted on raw saldo-vs-1630 gaps that unbooked rows explain.
|
|
eventHandlers: [],
|
|
|
|
// 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,
|
|
// Commit side of the staged book_skattekonto_row(s) MCP ops: books
|
|
// already-synced skattekonto rows through the bookkeeping engine.
|
|
commitBookSkattekontoRows,
|
|
// Read service for the v1 REST endpoint (issue #1663): filed
|
|
// momsdeklarationer (inlamnat) and beslut (beslutat). Contract in
|
|
// lib/skatteverket/declaration-status.ts.
|
|
fetchVatDeclarationStatus,
|
|
},
|
|
}
|
|
|
|
// ── 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 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ö.',
|
|
}
|
|
|
|
/**
|
|
* Commit service for the staged book_skattekonto_row / book_skattekonto_rows
|
|
* MCP operations. Registry-resolved by lib/pending-operations/commit.ts on
|
|
* approval. Books already-synced skattekonto_transactions rows as posted
|
|
* verifikat through the SAME batch helper the HTTP bokfor-batch route uses
|
|
* (draft + commit per row via the bookkeeping engine, requireSettled): no
|
|
* SKV API call is involved. Row failures come back as per-row data, never as
|
|
* a thrown error, so a partial batch commits with the failures listed.
|
|
*
|
|
* Gated on SKATTEVERKET_ENABLED for parity with the HTTP surface: the
|
|
* dispatcher blocks the whole extension route family behind that flag, so a
|
|
* direct lib call must not book where the web app could not. Recoverable:
|
|
* the op stays reviewable and a re-approve works once the env is enabled.
|
|
*/
|
|
async function commitBookSkattekontoRows(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
companyId: string,
|
|
params: Record<string, unknown>,
|
|
): Promise<SkattekontoBookCommitResult> {
|
|
if (!skatteverketEnabled()) return EXTENSION_DISABLED_RESULT
|
|
|
|
const raw = params.ids
|
|
const ids = Array.isArray(raw)
|
|
? [...new Set(raw.filter((v): v is string => typeof v === 'string' && v.length > 0))]
|
|
: []
|
|
if (ids.length === 0 || ids.length > 200) {
|
|
return {
|
|
ok: false,
|
|
code: 'INVALID_PARAMS',
|
|
http_status: 400,
|
|
recoverable: false,
|
|
error: 'ids måste vara en lista med 1-200 transaktions-id.',
|
|
}
|
|
}
|
|
|
|
try {
|
|
const result = await bokforSkattekontoTransactionsBatch(supabase, companyId, userId, ids)
|
|
return { ok: true, ...result }
|
|
} catch (err) {
|
|
// bokforSkattekontoTransactionsBatch catches per-row errors itself; a
|
|
// throw here is a batch-level failure (e.g. rule-context load): internal,
|
|
// non-recoverable, the user re-stages after the underlying issue is fixed.
|
|
log.error('commitBookSkattekontoRows failed', {
|
|
companyId,
|
|
rowCount: ids.length,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
})
|
|
// Fixed public message: the raw exception stays in the server log above
|
|
// and must not flow back to API callers (it can carry DB/row details).
|
|
return {
|
|
ok: false,
|
|
code: 'SKATTEKONTO_BOOKING_FAILED',
|
|
http_status: 500,
|
|
recoverable: false,
|
|
error: 'Skattekonto-raderna kunde inte bokföras. Försök igen eller kontakta support.',
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
}
|
|
}
|