* fix(invoices): apply configured voucher series to payments + preview next voucher The booking engine resolves the series from default_voucher_series_per_source_type, but the global "Standardserie" dropdown wrote a separate field the engine ignored, and cash-method invoice payments (invoice_cash_payment) weren't exposed in settings — so configured series were silently dropped to "A". - Expose cash/private payment source types in the per-source-type form - Write the global default through to the map on save, keeping overrides - Resolve voucher-sequences/next by source_type (+date) to match the engine - Show the upcoming voucher (V2) in the payment dialog title - Share resolveInvoicePaymentSourceType so preview and booking can't drift Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(salary): keep AGI panel in sync with Skatteverket signing state The AGI panel mixed run-scoped generation state (agi_generated_at, agi_declarations) with period-scoped submission state (extension_data agi_submission_{period}), so the two could drift and present contradictory UI. Reconcile them: - Auto-detect a Mina Sidor BankID signature: while awaiting_signing, poll /agi/kvittenser on mount and on tab refocus so the panel flips to "signed" (hiding the signing actions) without a manual "Hamta kvittens" click. - Warn instead of offering to sign when the locked granskningsunderlag predates the run's latest AGI generation (draftIsStale) — avoids filing superseded figures. - Self-heal a stale "AGI-XML saknas" error once the run's AGI is (re)generated out-of-band (MCP/API/other tab). - Refetch the salary run on tab focus so agi_generated_at reflects out-of-band generation without a hard reload. - /agi/lasUpp now clears the cached agi_submission_{period} record, so unlocking drops the panel back to the pre-submission state instead of stranding it on a released "redo att signeras" draft. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: Implement VAT registration handling and invoice item line types - Added VAT registration check in commitCreateInvoice to set VAT rate to 0% for non-VAT registered companies. - Updated invoice creation logic to reflect 'exempt' VAT treatment and adjusted related fields accordingly. - Introduced support for free-text and blank spacer rows in invoice items by adding a new line_type field. - Enhanced invoice and credit note handling to accommodate new line types. - Added new localized messages for text rows in English and Swedish. - Created tests for salary run approval logic, ensuring bank details are validated correctly. - Implemented effective net payout calculation for salary runs, considering tax overrides. - Added SQL migrations to support new invoice item line types and accounting method awareness for linking invoices to vouchers. * feat(articles): artikelregister with revenue account + VAT rate per article Article register (non-inventory) with per-article VAT rate and optional BAS class-3 revenue-account override. Includes API routes, UI pages, MCP tools, pending-operation staging, and the activate-or-create account flow (ACCOUNTS_NOT_IN_CHART -> ActivateAccountsDialog, unknown numbers -> AddAccountDialog) reusing the journal entry UX. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): no-doc-required batch + bulk-missing endpoints Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(payments): supplier payment lines + cash-method invoice matching Shared payment-line proposal for supplier invoices, improved match-invoice/match-supplier-invoice flows (kontantmetoden-aware), and voucher-link support without requiring a 151x clearing entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): new journal entry dialog, SIE import tweaks, misc New journal entry dialog component, journal list/page updates, invoice editor updates, SIE import adjustments, transaction ingest and api-key tweaks, pr-agent workflow update. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(invoices): implement tax reduction features and localization updates * feat(tests): add VAT registration gate to pending operations commit tests --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2237 lines
90 KiB
TypeScript
2237 lines
90 KiB
TypeScript
import crypto from 'crypto'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
|
|
import { NextResponse } from 'next/server'
|
|
import {
|
|
AGIKontrolleraHUSchema,
|
|
AGIKontrolleraIUSchema,
|
|
AGI_KONTROLLERA_MAX_BYTES,
|
|
} from '@/lib/salary/agi/kontrollera-schemas'
|
|
import { TimeoutError } from '@/lib/http/fetch-with-timeout'
|
|
import { buildAuthorizeUrl, exchangeCodeForTokens, generatePkcePair } from './lib/oauth'
|
|
import { storeTokens, getTokens, deleteTokens } from './lib/token-store'
|
|
import { skvRequest, SkatteverketAuthError, getSkatteverketEnvironment } from './lib/api-client'
|
|
import { writeSkatteverketAudit } from './lib/audit'
|
|
import { skvAuthCodeToStructured } from './lib/error-map'
|
|
import {
|
|
buildMomsuppgift,
|
|
buildAgiUnderlag,
|
|
type VatDeclarationPrep,
|
|
type AgiUnderlagPrep,
|
|
} from './lib/declaration-prep'
|
|
import { createExtensionContext } from '@/lib/extensions/context-factory'
|
|
import type { SkvSubmitResult } from '@/lib/pending-operations/skatteverket-commit'
|
|
import {
|
|
agiPostUnderlag,
|
|
agiGetKontrollresultat,
|
|
agiSparaUnderlag,
|
|
agiAvbrytUnderlag,
|
|
agiTaBortSparadInlamning,
|
|
agiSkapaGranskningsunderlag,
|
|
agiGetKvittenser,
|
|
agiLasPeriod,
|
|
agiLasUppPeriod,
|
|
agiKontrolleraHU,
|
|
agiKontrolleraIU,
|
|
} from './lib/agi-client'
|
|
import { syncSkattekonto, SKATTEKONTO_BALANCE_SNAPSHOT_KEY, SKATTEKONTO_LAST_SYNCED_AT_KEY } from './lib/skattekonto-sync'
|
|
import { bokforSkattekontoTransaction, SkattekontoBookingError } from './lib/skattekonto-booking'
|
|
import { handleSkattekontoDriftDetected } from './lib/skattekonto-drift-email'
|
|
import {
|
|
findMatchCandidates,
|
|
findMatchSuggestionsBulk,
|
|
matchSkattekontoToEntry,
|
|
SkattekontoMatchError,
|
|
} from './lib/skattekonto-match'
|
|
import { splitTransactions } from './lib/skattekonto-buckets'
|
|
import type { SkattekontoBalanceSnapshot, SkatteverketUtkastResponse } from './types'
|
|
import type { VatPeriodType } from '@/types'
|
|
|
|
/**
|
|
* Skatteverket integration extension.
|
|
*
|
|
* Enables filing momsdeklaration (VAT declaration) and arbetsgivardeklaration
|
|
* (AGI), plus Skattekonto saldo sync. Users authenticate with BankID via the
|
|
* `per` (e-legitimation) OAuth2 flow.
|
|
*
|
|
* Required environment variables:
|
|
* - SKATTEVERKET_OAUTH2_CLIENT_ID
|
|
* - SKATTEVERKET_OAUTH2_CLIENT_SECRET
|
|
* - SKATTEVERKET_APIGW_CLIENT_ID
|
|
* - SKATTEVERKET_APIGW_CLIENT_SECRET
|
|
* - SKATTEVERKET_TOKEN_ENCRYPTION_KEY (openssl rand -base64 32; never reuse
|
|
* the test-env key in prod)
|
|
*
|
|
* Optional:
|
|
* - SKATTEVERKET_ENV — 'test' | 'production'.
|
|
* Drives security-relevant
|
|
* limits (AGI payload size:
|
|
* 100 MB test vs 300 MB prod).
|
|
* Defaults to 'test' (stricter)
|
|
* when unset or unrecognised.
|
|
* MUST be set explicitly in
|
|
* every deployment manifest.
|
|
* - SKATTEVERKET_OAUTH_BASE_URL — defaults to test
|
|
* - SKATTEVERKET_API_BASE_URL — momsdeklaration; defaults to test
|
|
* - SKATTEVERKET_AGD_INLAMNING_API_BASE_URL — AGI inlämning; defaults to test
|
|
* - SKATTEVERKET_AGD_PERIOD_API_BASE_URL — AGI period mgmt; defaults to test
|
|
* - SKATTEVERKET_SKATTEKONTO_API_BASE_URL — Skattekonto; defaults to test
|
|
* - SKATTEVERKET_DISABLED=true — emergency kill switch
|
|
*
|
|
* ─── Production cutover checklist ─────────────────────────────────────────
|
|
* Before flipping the env URLs to prod, the following has to land first
|
|
* (most are external blockers):
|
|
*
|
|
* 1. Register a prod OAuth2 client in Skatteverket's developer portal
|
|
* (separate from the test client). Requires a signed integrationsavtal.
|
|
* 2. Order APIGW prod credentials (separate ärende).
|
|
* 3. Register the prod redirect URI:
|
|
* `${NEXT_PUBLIC_APP_URL}/api/extensions/ext/skatteverket/callback`.
|
|
* 4. Request scopes: agd:skicka, agd:lasa, skattekonto:lasa, moms:skicka.
|
|
* 5. Pass Skatteverket's godkännandetest (they validate a few real AGI
|
|
* submissions in their test tenant before granting prod access).
|
|
* 6. Generate a fresh SKATTEVERKET_TOKEN_ENCRYPTION_KEY (rotate from test).
|
|
* 7. Set the prod base URLs:
|
|
* SKATTEVERKET_API_BASE_URL=https://api.skatteverket.se/momsdeklaration/v1
|
|
* SKATTEVERKET_AGD_INLAMNING_API_BASE_URL=https://api.skatteverket.se/arbetsgivardeklaration/inlamning/v1
|
|
* SKATTEVERKET_AGD_PERIOD_API_BASE_URL=https://api.skatteverket.se/arbetsgivardeklaration/hanteraredovisningsperiod/v1
|
|
* SKATTEVERKET_SKATTEKONTO_API_BASE_URL=https://api.skatteverket.se/beskattning/skattekonto/v2
|
|
* SKATTEVERKET_OAUTH_BASE_URL=https://oauth2.skatteverket.se/oauth2
|
|
* 8. Verify Sentry alerts on /api/extensions/ext/skatteverket/* 5xx.
|
|
* 9. Verify 7-year retention of `agi_declarations.xml_content` +
|
|
* `kvittensnummer` (BFL 7 kap.).
|
|
* 10. Run a single AGI end-to-end against test on a real client before
|
|
* switching that client over.
|
|
*
|
|
* The /status endpoint reports which environment is active so the UI can
|
|
* surface a Testmiljö / Produktion badge.
|
|
*/
|
|
|
|
const AGI_WRITE_ROLES = new Set(['owner', 'admin', 'member'])
|
|
|
|
/**
|
|
* Defense-in-depth RBAC check for AGI write/validate endpoints. Ctx
|
|
* presence alone (set by middleware) only confirms the user is signed in
|
|
* and has a resolved company; it does NOT prove they are entitled to
|
|
* submit tax declarations for that company. Viewers must be blocked.
|
|
*
|
|
* Returns null on success, a NextResponse on failure.
|
|
*/
|
|
async function requireAgiWriteRole(ctx: ExtensionContext): Promise<NextResponse | null> {
|
|
const { data, error } = await ctx.supabase
|
|
.from('company_members')
|
|
.select('role')
|
|
.eq('company_id', ctx.companyId)
|
|
.eq('user_id', ctx.userId)
|
|
.maybeSingle()
|
|
|
|
if (error) {
|
|
return NextResponse.json(
|
|
{ error: 'Behörighetskontroll misslyckades.' },
|
|
{ status: 500 },
|
|
)
|
|
}
|
|
if (!data?.role || !AGI_WRITE_ROLES.has(data.role as string)) {
|
|
return NextResponse.json(
|
|
{ error: 'Otillräcklig behörighet för att lämna in AGI för det här företaget.' },
|
|
{ status: 403 },
|
|
)
|
|
}
|
|
return null
|
|
}
|
|
|
|
export const skatteverketExtension: Extension = {
|
|
id: 'skatteverket',
|
|
name: 'Skatteverket Integration',
|
|
version: '1.0.0',
|
|
|
|
settingsPanel: {
|
|
label: 'Skatteverket',
|
|
path: '/settings/account',
|
|
},
|
|
|
|
apiRoutes: [
|
|
// ── OAuth: Start authorization ──────────────────────────────────
|
|
// Builds the Skatteverket OAuth2 authorize URL and redirects the user
|
|
// to BankID login. Stores state token in extension settings for CSRF validation.
|
|
{
|
|
method: 'GET',
|
|
path: '/authorize',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
if (!ctx) {
|
|
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
|
}
|
|
|
|
const state = crypto.randomUUID()
|
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
|
|
const redirectUri = `${appUrl}/api/extensions/ext/skatteverket/callback`
|
|
|
|
// Optional: where to send the user after the BankID round-trip.
|
|
// Allowlisted to internal in-app paths to avoid open-redirect abuse.
|
|
const url = new URL(request.url)
|
|
const requestedReturn = url.searchParams.get('return_to')
|
|
const returnTo =
|
|
requestedReturn && requestedReturn.startsWith('/') && !requestedReturn.startsWith('//')
|
|
? requestedReturn
|
|
: null
|
|
|
|
// Generate PKCE pair — verifier persisted server-side, challenge sent
|
|
// to SKV. Some SKV per-flow client configurations issue revoked-on-use
|
|
// tokens unless PKCE is present, so we always send it.
|
|
const pkce = generatePkcePair()
|
|
|
|
// Store state for CSRF validation in callback
|
|
await ctx.settings.set('oauth_state', state)
|
|
await ctx.settings.set('oauth_redirect_uri', redirectUri)
|
|
await ctx.settings.set('oauth_code_verifier', pkce.verifier)
|
|
if (returnTo) await ctx.settings.set('oauth_return_to', returnTo)
|
|
else await ctx.settings.clear('oauth_return_to')
|
|
|
|
const authorizeUrl = buildAuthorizeUrl(redirectUri, state, {
|
|
codeChallenge: pkce.challenge,
|
|
})
|
|
|
|
return NextResponse.redirect(authorizeUrl)
|
|
},
|
|
},
|
|
|
|
// ── OAuth: Callback ─────────────────────────────────────────────
|
|
// Receives the auth code from Skatteverket after BankID login.
|
|
// Exchanges code for tokens immediately (5-minute code expiry).
|
|
// skipAuth: true — browser redirect from Skatteverket. We handle
|
|
// user identification via the stored state token + Supabase session.
|
|
{
|
|
method: 'GET',
|
|
path: '/callback',
|
|
skipAuth: true,
|
|
handler: async (request: Request) => {
|
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
|
|
const url = new URL(request.url)
|
|
const code = url.searchParams.get('code')
|
|
const state = url.searchParams.get('state')
|
|
const error = url.searchParams.get('error')
|
|
|
|
// JSON-encode for safe embedding inside <script>. Escapes quotes and `</`
|
|
// so the value can't break out of the script tag.
|
|
const jsLiteral = (value: unknown) =>
|
|
JSON.stringify(value ?? '').replace(/</g, '\\u003c')
|
|
|
|
// Build an HTML response that detects whether we're running inside an
|
|
// OAuth popup. If `window.opener` exists, post a message back to the
|
|
// parent and close the popup. Otherwise fall back to a plain redirect
|
|
// (preserves the legacy non-popup connect flow).
|
|
const respondWithSuccess = (fallbackPath: string) => {
|
|
const html = `<!DOCTYPE html><html><body><script>
|
|
if (window.opener) {
|
|
window.opener.postMessage({ type: 'skatteverket-oauth-success' }, ${jsLiteral(appUrl)});
|
|
window.close();
|
|
} else {
|
|
window.location.href = ${jsLiteral(`${appUrl}${fallbackPath}`)};
|
|
}
|
|
</script><p>Anslutningen lyckades. Du kan stänga denna flik.</p></body></html>`
|
|
return new Response(html, {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
})
|
|
}
|
|
|
|
const respondWithError = (reason: string, fallbackPath: string) => {
|
|
const escapedReason = reason
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
const html = `<!DOCTYPE html><html><body><script>
|
|
if (window.opener) {
|
|
window.opener.postMessage({ type: 'skatteverket-oauth-error', reason: ${jsLiteral(reason)} }, ${jsLiteral(appUrl)});
|
|
window.close();
|
|
} else {
|
|
window.location.href = ${jsLiteral(`${appUrl}${fallbackPath}`)};
|
|
}
|
|
</script><p>Anslutningen misslyckades: ${escapedReason}</p></body></html>`
|
|
return new Response(html, {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
})
|
|
}
|
|
|
|
if (error) {
|
|
const desc = url.searchParams.get('error_description') || 'Okänt fel'
|
|
return respondWithError(
|
|
desc,
|
|
`/reports?tab=vat-declaration&skv_error=${encodeURIComponent(desc)}`,
|
|
)
|
|
}
|
|
|
|
if (!code || !state) {
|
|
return respondWithError(
|
|
'Saknar auktoriseringskod',
|
|
`/reports?tab=vat-declaration&skv_error=${encodeURIComponent('Saknar auktoriseringskod')}`,
|
|
)
|
|
}
|
|
|
|
// Exchange code FIRST — 5-minute expiry, do this before anything else
|
|
const { createClient } = await import('@/lib/supabase/server')
|
|
const { requireCompanyId } = await import('@/lib/company/context')
|
|
const supabase = await createClient()
|
|
|
|
// Verify user session (browser should still have cookies)
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
if (!user) {
|
|
// Login redirects always go to a full page — popups can't render the
|
|
// login form usefully, so this is the one path that keeps a hard
|
|
// redirect even from inside the popup.
|
|
return NextResponse.redirect(
|
|
`${appUrl}/login?redirect=${encodeURIComponent('/reports?tab=vat-declaration')}`
|
|
)
|
|
}
|
|
|
|
// Resolve the active company — state/redirect_uri were stored keyed on company_id
|
|
// by ctx.settings.set() in the /authorize handler.
|
|
let companyId: string
|
|
try {
|
|
companyId = await requireCompanyId(supabase, user.id)
|
|
} catch {
|
|
return respondWithError(
|
|
'Inget företag valt',
|
|
`/reports?tab=vat-declaration&skv_error=${encodeURIComponent('Inget företag valt')}`,
|
|
)
|
|
}
|
|
|
|
// Validate CSRF state
|
|
const { data: settingsData } = await supabase
|
|
.from('extension_data')
|
|
.select('value')
|
|
.eq('company_id', companyId)
|
|
.eq('extension_id', 'skatteverket')
|
|
.eq('key', 'oauth_state')
|
|
.single()
|
|
|
|
if (!settingsData || settingsData.value !== state) {
|
|
return respondWithError(
|
|
'Ogiltig state-parameter (CSRF)',
|
|
`/reports?tab=vat-declaration&skv_error=${encodeURIComponent('Ogiltig state-parameter (CSRF)')}`,
|
|
)
|
|
}
|
|
|
|
// Get the stored redirect URI
|
|
const { data: redirectData } = await supabase
|
|
.from('extension_data')
|
|
.select('value')
|
|
.eq('company_id', companyId)
|
|
.eq('extension_id', 'skatteverket')
|
|
.eq('key', 'oauth_redirect_uri')
|
|
.single()
|
|
|
|
const redirectUri = redirectData?.value ||
|
|
`${appUrl}/api/extensions/ext/skatteverket/callback`
|
|
|
|
// Retrieve the PKCE verifier stored in /authorize. Optional only for
|
|
// backward compatibility with in-flight flows that started before the
|
|
// PKCE rollout — once those drain, this can be made required.
|
|
const { data: verifierData } = await supabase
|
|
.from('extension_data')
|
|
.select('value')
|
|
.eq('company_id', companyId)
|
|
.eq('extension_id', 'skatteverket')
|
|
.eq('key', 'oauth_code_verifier')
|
|
.maybeSingle()
|
|
|
|
const codeVerifier = (verifierData?.value as string | null) || undefined
|
|
|
|
// Optional in-app destination set by /authorize?return_to=...
|
|
const { data: returnToData } = await supabase
|
|
.from('extension_data')
|
|
.select('value')
|
|
.eq('company_id', companyId)
|
|
.eq('extension_id', 'skatteverket')
|
|
.eq('key', 'oauth_return_to')
|
|
.maybeSingle()
|
|
|
|
const returnTo = (returnToData?.value as string | null) || null
|
|
const successPath = returnTo
|
|
? `${returnTo}${returnTo.includes('?') ? '&' : '?'}skv_connected=true`
|
|
: `/reports?tab=vat-declaration&skv_connected=true`
|
|
const errorPath = (msg: string) =>
|
|
returnTo
|
|
? `${returnTo}${returnTo.includes('?') ? '&' : '?'}skv_error=${encodeURIComponent(msg)}`
|
|
: `/reports?tab=vat-declaration&skv_error=${encodeURIComponent(msg)}`
|
|
|
|
try {
|
|
const tokens = await exchangeCodeForTokens(code, redirectUri, codeVerifier)
|
|
await storeTokens(supabase, user.id, tokens, companyId)
|
|
|
|
// Clean up CSRF state + the one-shot return_to + PKCE verifier.
|
|
await supabase
|
|
.from('extension_data')
|
|
.delete()
|
|
.eq('company_id', companyId)
|
|
.eq('extension_id', 'skatteverket')
|
|
.in('key', ['oauth_state', 'oauth_return_to', 'oauth_code_verifier'])
|
|
|
|
return respondWithSuccess(successPath)
|
|
} catch (err) {
|
|
console.error('[skatteverket] Token exchange failed:', err)
|
|
// BankID auth codes expire after 5 minutes. Surface timeouts distinctly
|
|
// so the user retries quickly instead of exhausting the code window.
|
|
const message = err instanceof TimeoutError
|
|
? 'Tidsgränsen mot Skatteverket överskreds — försök igen med BankID'
|
|
: err instanceof Error
|
|
? err.message
|
|
: 'Token exchange misslyckades'
|
|
return respondWithError(message, errorPath(message))
|
|
}
|
|
},
|
|
},
|
|
|
|
// ── Connection status ───────────────────────────────────────────
|
|
{
|
|
method: 'GET',
|
|
path: '/status',
|
|
handler: async (_request: Request, ctx?: ExtensionContext) => {
|
|
if (!ctx) {
|
|
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
|
}
|
|
|
|
const tokens = await getTokens(ctx.supabase, ctx.userId)
|
|
const environment = getSkatteverketEnvironment()
|
|
const disabled = (process.env.SKATTEVERKET_DISABLED ?? '').toLowerCase() === 'true'
|
|
|
|
if (!tokens) {
|
|
return NextResponse.json({ connected: false, environment, disabled })
|
|
}
|
|
|
|
const expired = tokens.expires_at < Date.now()
|
|
const canRefresh = tokens.refresh_token !== null && tokens.refresh_count < 10
|
|
|
|
return NextResponse.json({
|
|
connected: true,
|
|
expired,
|
|
canRefresh,
|
|
scope: tokens.scope,
|
|
expiresAt: new Date(tokens.expires_at).toISOString(),
|
|
environment,
|
|
disabled,
|
|
})
|
|
},
|
|
},
|
|
|
|
// ── Disconnect ──────────────────────────────────────────────────
|
|
{
|
|
method: 'POST',
|
|
path: '/disconnect',
|
|
handler: async (_request: Request, ctx?: ExtensionContext) => {
|
|
if (!ctx) {
|
|
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
|
}
|
|
|
|
await deleteTokens(ctx.supabase, ctx.userId)
|
|
return NextResponse.json({ success: true })
|
|
},
|
|
},
|
|
|
|
// ── Validate declaration (dry run) ──────────────────────────────
|
|
// Sends momsuppgift to Skatteverket's /kontrollera endpoint.
|
|
// Returns ERROR/WARNING/OK without saving anything.
|
|
{
|
|
method: 'POST',
|
|
path: '/declaration/validate',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
if (!ctx) {
|
|
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
|
}
|
|
|
|
try {
|
|
const { redovisare, redovisningsperiod, momsuppgift } =
|
|
await parseDeclarationRequest(request, ctx)
|
|
|
|
console.log('[skatteverket] Validating:', {
|
|
redovisare,
|
|
redovisningsperiod,
|
|
momsuppgift: JSON.stringify(momsuppgift),
|
|
})
|
|
|
|
const response = await skvRequest(
|
|
ctx.supabase,
|
|
ctx.userId,
|
|
'POST',
|
|
`/kontrollera/${redovisare}/${redovisningsperiod}`,
|
|
momsuppgift
|
|
)
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text()
|
|
console.error('[skatteverket] Validate error:', response.status, text)
|
|
return NextResponse.json(
|
|
{ error: `Skatteverket svarade med ${response.status}: ${text}` },
|
|
{ status: response.status }
|
|
)
|
|
}
|
|
|
|
const data = await response.json()
|
|
return NextResponse.json({ data })
|
|
} catch (err) {
|
|
return handleSkvError(err)
|
|
}
|
|
},
|
|
},
|
|
|
|
// ── Save draft ──────────────────────────────────────────────────
|
|
// Saves momsuppgift to Skatteverket's "Eget utrymme".
|
|
// Returns validation results. Optionally lock for signing.
|
|
{
|
|
method: 'POST',
|
|
path: '/declaration/draft',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
if (!ctx) {
|
|
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
|
}
|
|
|
|
try {
|
|
const { redovisare, redovisningsperiod, momsuppgift } =
|
|
await parseDeclarationRequest(request, ctx)
|
|
|
|
console.log('[skatteverket] Sending draft:', {
|
|
redovisare,
|
|
redovisningsperiod,
|
|
momsuppgift: JSON.stringify(momsuppgift),
|
|
})
|
|
|
|
const response = await skvRequest(
|
|
ctx.supabase,
|
|
ctx.userId,
|
|
'POST',
|
|
`/utkast/${redovisare}/${redovisningsperiod}`,
|
|
momsuppgift
|
|
)
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text()
|
|
console.error('[skatteverket] Draft error:', response.status, text)
|
|
return NextResponse.json(
|
|
{ error: `Skatteverket svarade med ${response.status}: ${text}` },
|
|
{ status: response.status }
|
|
)
|
|
}
|
|
|
|
const data = await response.json()
|
|
|
|
// Track submission status
|
|
await ctx.settings.set(
|
|
`submission_${redovisningsperiod}`,
|
|
JSON.stringify({
|
|
status: 'draft_saved',
|
|
redovisare,
|
|
redovisningsperiod,
|
|
kontrollresultat: data.kontrollresultat,
|
|
updatedAt: new Date().toISOString(),
|
|
})
|
|
)
|
|
|
|
return NextResponse.json({ data })
|
|
} catch (err) {
|
|
return handleSkvError(err)
|
|
}
|
|
},
|
|
},
|
|
|
|
// ── Fetch draft ─────────────────────────────────────────────────
|
|
{
|
|
method: 'GET',
|
|
path: '/declaration/draft',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
if (!ctx) {
|
|
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
|
}
|
|
|
|
try {
|
|
const { redovisare, redovisningsperiod } = parseQueryParams(request, ctx)
|
|
|
|
const response = await skvRequest(
|
|
ctx.supabase,
|
|
ctx.userId,
|
|
'GET',
|
|
`/utkast/${redovisare}/${redovisningsperiod}`
|
|
)
|
|
|
|
if (response.status === 404) {
|
|
return NextResponse.json({ data: null })
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text()
|
|
return NextResponse.json(
|
|
{ error: `Skatteverket svarade med ${response.status}: ${text}` },
|
|
{ status: response.status }
|
|
)
|
|
}
|
|
|
|
const data = await response.json()
|
|
return NextResponse.json({ data })
|
|
} catch (err) {
|
|
return handleSkvError(err)
|
|
}
|
|
},
|
|
},
|
|
|
|
// ── Delete draft ────────────────────────────────────────────────
|
|
{
|
|
method: 'DELETE',
|
|
path: '/declaration/draft',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
if (!ctx) {
|
|
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
|
}
|
|
|
|
try {
|
|
const { redovisare, redovisningsperiod } = parseQueryParams(request, ctx)
|
|
|
|
const response = await skvRequest(
|
|
ctx.supabase,
|
|
ctx.userId,
|
|
'DELETE',
|
|
`/utkast/${redovisare}/${redovisningsperiod}`
|
|
)
|
|
|
|
if (response.status !== 204 && !response.ok) {
|
|
const text = await response.text()
|
|
return NextResponse.json(
|
|
{ error: `Skatteverket svarade med ${response.status}: ${text}` },
|
|
{ status: response.status }
|
|
)
|
|
}
|
|
|
|
await ctx.settings.clear(`submission_${redovisningsperiod}`)
|
|
return NextResponse.json({ success: true })
|
|
} catch (err) {
|
|
return handleSkvError(err)
|
|
}
|
|
},
|
|
},
|
|
|
|
// ── Lock draft for signing ──────────────────────────────────────
|
|
// Returns a signeringslänk (deep link) that the user opens
|
|
// in a new tab to sign with BankID on Skatteverket's site.
|
|
{
|
|
method: 'PUT',
|
|
path: '/declaration/lock',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
if (!ctx) {
|
|
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
|
}
|
|
|
|
try {
|
|
const { redovisare, redovisningsperiod } = parseQueryParams(request, ctx)
|
|
|
|
const response = await skvRequest(
|
|
ctx.supabase,
|
|
ctx.userId,
|
|
'PUT',
|
|
`/las/${redovisare}/${redovisningsperiod}`
|
|
)
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text()
|
|
return NextResponse.json(
|
|
{ error: `Skatteverket svarade med ${response.status}: ${text}` },
|
|
{ status: response.status }
|
|
)
|
|
}
|
|
|
|
const data = await response.json()
|
|
|
|
await ctx.settings.set(
|
|
`submission_${redovisningsperiod}`,
|
|
JSON.stringify({
|
|
status: 'draft_locked',
|
|
redovisare,
|
|
redovisningsperiod,
|
|
signeringsLank: data.signeringsLank,
|
|
updatedAt: new Date().toISOString(),
|
|
})
|
|
)
|
|
|
|
return NextResponse.json({ data })
|
|
} catch (err) {
|
|
return handleSkvError(err)
|
|
}
|
|
},
|
|
},
|
|
|
|
// ── Unlock draft ────────────────────────────────────────────────
|
|
{
|
|
method: 'DELETE',
|
|
path: '/declaration/lock',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
if (!ctx) {
|
|
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
|
}
|
|
|
|
try {
|
|
const { redovisare, redovisningsperiod } = parseQueryParams(request, ctx)
|
|
|
|
const response = await skvRequest(
|
|
ctx.supabase,
|
|
ctx.userId,
|
|
'DELETE',
|
|
`/las/${redovisare}/${redovisningsperiod}`
|
|
)
|
|
|
|
if (response.status !== 204 && !response.ok) {
|
|
const text = await response.text()
|
|
return NextResponse.json(
|
|
{ error: `Skatteverket svarade med ${response.status}: ${text}` },
|
|
{ status: response.status }
|
|
)
|
|
}
|
|
|
|
await ctx.settings.set(
|
|
`submission_${redovisningsperiod}`,
|
|
JSON.stringify({
|
|
status: 'draft_saved',
|
|
redovisare,
|
|
redovisningsperiod,
|
|
updatedAt: new Date().toISOString(),
|
|
})
|
|
)
|
|
|
|
return NextResponse.json({ success: true })
|
|
} catch (err) {
|
|
return handleSkvError(err)
|
|
}
|
|
},
|
|
},
|
|
|
|
// ── Fetch submitted declaration ─────────────────────────────────
|
|
{
|
|
method: 'GET',
|
|
path: '/declaration/submitted',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
if (!ctx) {
|
|
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
|
}
|
|
|
|
try {
|
|
const { redovisare, redovisningsperiod } = parseQueryParams(request, ctx)
|
|
|
|
const response = await skvRequest(
|
|
ctx.supabase,
|
|
ctx.userId,
|
|
'GET',
|
|
`/inlamnat/${redovisare}/${redovisningsperiod}`
|
|
)
|
|
|
|
if (response.status === 404) {
|
|
return NextResponse.json({ data: null })
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text()
|
|
return NextResponse.json(
|
|
{ error: `Skatteverket svarade med ${response.status}: ${text}` },
|
|
{ status: response.status }
|
|
)
|
|
}
|
|
|
|
const data = await response.json()
|
|
return NextResponse.json({ data })
|
|
} catch (err) {
|
|
return handleSkvError(err)
|
|
}
|
|
},
|
|
},
|
|
|
|
// ── Fetch decided declaration ───────────────────────────────────
|
|
{
|
|
method: 'GET',
|
|
path: '/declaration/decided',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
if (!ctx) {
|
|
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
|
}
|
|
|
|
try {
|
|
const { redovisare, redovisningsperiod } = parseQueryParams(request, ctx)
|
|
|
|
const response = await skvRequest(
|
|
ctx.supabase,
|
|
ctx.userId,
|
|
'GET',
|
|
`/beslutat/${redovisare}/${redovisningsperiod}`
|
|
)
|
|
|
|
if (response.status === 404) {
|
|
return NextResponse.json({ data: null })
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text()
|
|
return NextResponse.json(
|
|
{ error: `Skatteverket svarade med ${response.status}: ${text}` },
|
|
{ status: response.status }
|
|
)
|
|
}
|
|
|
|
const data = await response.json()
|
|
return NextResponse.json({ data })
|
|
} catch (err) {
|
|
return handleSkvError(err)
|
|
}
|
|
},
|
|
},
|
|
// ══════════════════════════════════════════════════════════════
|
|
// AGI (Arbetsgivardeklaration) routes
|
|
//
|
|
// AGI submission is XML, not JSON. We feed agi_declarations.xml_content
|
|
// (built by lib/salary/agi/xml-generator.ts) to POST /underlag, then poll
|
|
// kontrollresultat, save into Eget utrymme, and return a Mina Sidor
|
|
// signing link via skapaGranskningsunderlag. After the user signs we
|
|
// observe the kvittenser endpoint to record kvittensnummer/signeradTid.
|
|
//
|
|
// The route surface mirrors the conceptual flow rather than the literal
|
|
// SKV endpoints so the frontend stays simple. Two SKV APIs are involved:
|
|
// inlamning (XML ingest + JSON status) and hanteraredovisningsperiod
|
|
// (kvittenser + las/lasUpp). The agi-client encapsulates both.
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
// ── AGI: Submit (POST /underlag with stored XML) ────────────────
|
|
// Body: { salaryRunId }. Reads agi_declarations.xml_content for the run,
|
|
// posts it to Skatteverket, returns { inlamningId } so the caller can
|
|
// poll kontrollresultat. Also persists inlamningId locally for recovery.
|
|
{
|
|
method: 'POST',
|
|
path: '/agi/submit',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
if (!ctx) {
|
|
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
|
}
|
|
try {
|
|
const { arbetsgivare, period, salaryRunId, xml } = await loadAGIXml(request, ctx)
|
|
|
|
console.log('[skatteverket] AGI submitting underlag:', { arbetsgivare, period })
|
|
|
|
const result = await agiPostUnderlag(ctx.supabase, ctx.userId, xml)
|
|
if (!result.ok) {
|
|
console.error('[skatteverket] AGI underlag error:', result.status, result.error)
|
|
return NextResponse.json(
|
|
{ error: result.error, code: result.body?.kod },
|
|
{ status: result.status },
|
|
)
|
|
}
|
|
|
|
await ctx.settings.set(
|
|
`agi_submission_${period}`,
|
|
JSON.stringify({
|
|
status: 'underlag_submitted',
|
|
arbetsgivare,
|
|
period,
|
|
salaryRunId,
|
|
inlamningId: result.data.inlamningId,
|
|
updatedAt: new Date().toISOString(),
|
|
}),
|
|
)
|
|
|
|
// Don't flip agi_declarations.status to 'exported' here. SKV's
|
|
// kontrollresultat may still come back DONE_REJECTED, in which case
|
|
// nothing landed in Eget utrymme. The transition belongs in
|
|
// /agi/spara below, after the user (or auto-spara on success) has
|
|
// committed the underlag.
|
|
|
|
return NextResponse.json({ data: result.data })
|
|
} catch (err) {
|
|
return handleSkvError(err)
|
|
}
|
|
},
|
|
},
|
|
|
|
// ── AGI: Poll kontrollresultat ──────────────────────────────────
|
|
// Query: ?inlamningId=...
|
|
// Returns { status: PROCESSING | DONE_SUCCESS | DONE_FAILED | DONE_REJECTED, ... }
|
|
//
|
|
// Side effect: when SKV reports a terminal failure (DONE_REJECTED or
|
|
// DONE_FAILED), promote the matching agi_declarations row to 'rejected'.
|
|
// Without this the row would sit at 'generated' indefinitely while SKV's
|
|
// own state shows the underlag as failed — misrepresenting the filing
|
|
// outcome (BFNAR 2013:2 kap 8 / BFL 5 kap 5§).
|
|
{
|
|
method: 'GET',
|
|
path: '/agi/kontrollresultat',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
if (!ctx) {
|
|
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
|
}
|
|
try {
|
|
const url = new URL(request.url)
|
|
const inlamningId = Number(url.searchParams.get('inlamningId'))
|
|
if (!Number.isFinite(inlamningId) || inlamningId <= 0) {
|
|
return NextResponse.json({ error: 'Saknar parameter: inlamningId' }, { status: 400 })
|
|
}
|
|
|
|
const result = await agiGetKontrollresultat(ctx.supabase, ctx.userId, inlamningId)
|
|
if (!result.ok) {
|
|
return NextResponse.json(
|
|
{ error: result.error, code: result.body?.kod },
|
|
{ status: result.status },
|
|
)
|
|
}
|
|
|
|
if (result.data.status === 'DONE_REJECTED' || result.data.status === 'DONE_FAILED') {
|
|
// Recover the salaryRunId from cached submission state — same
|
|
// fallback mechanism /agi/spara uses. We only update when we can
|
|
// identify the row; a missing local cache means we silently skip
|
|
// (the alternative would be guessing which declaration to mark).
|
|
const { data: rows } = await ctx.supabase
|
|
.from('extension_data')
|
|
.select('value')
|
|
.eq('company_id', ctx.companyId)
|
|
.eq('extension_id', 'skatteverket')
|
|
.like('key', 'agi_submission_%')
|
|
for (const row of rows ?? []) {
|
|
try {
|
|
const v = JSON.parse(row.value as string) as { inlamningId?: number; salaryRunId?: string }
|
|
if (v.inlamningId === inlamningId && v.salaryRunId) {
|
|
// Same monotonicity rule as /agi/spara — never regress
|
|
// from a successful filing back to 'rejected'.
|
|
await ctx.supabase
|
|
.from('agi_declarations')
|
|
.update({ status: 'rejected' })
|
|
.eq('salary_run_id', v.salaryRunId)
|
|
.eq('company_id', ctx.companyId)
|
|
.in('status', ['generated', 'pending_signature', 'exported'])
|
|
break
|
|
}
|
|
} catch { /* skip malformed */ }
|
|
}
|
|
}
|
|
|
|
return NextResponse.json({ data: result.data })
|
|
} catch (err) {
|
|
return handleSkvError(err)
|
|
}
|
|
},
|
|
},
|
|
|
|
// ── AGI: Save underlag into Eget utrymme ────────────────────────
|
|
// Body: { inlamningId, salaryRunId? }. Only meaningful between
|
|
// POST /underlag and skapaGranskningsunderlag.
|
|
//
|
|
// Flips agi_declarations.status to 'pending_signature' on success —
|
|
// the underlag is durable in SKV's Eget utrymme but is not yet a
|
|
// filed declaration. /agi/kvittenser later promotes it to 'submitted'
|
|
// when a uuidKvittens (signature receipt) is observed for the period.
|
|
// /agi/submit deliberately does NOT update status, because a
|
|
// DONE_REJECTED kontrollresultat would leave it falsely pending.
|
|
{
|
|
method: 'POST',
|
|
path: '/agi/spara',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
if (!ctx) {
|
|
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
|
}
|
|
try {
|
|
const body = (await request.json()) as { inlamningId?: number; salaryRunId?: string }
|
|
const inlamningId = Number(body.inlamningId)
|
|
if (!Number.isFinite(inlamningId) || inlamningId <= 0) {
|
|
return NextResponse.json({ error: 'Saknar inlamningId' }, { status: 400 })
|
|
}
|
|
|
|
const result = await agiSparaUnderlag(ctx.supabase, ctx.userId, inlamningId)
|
|
if (!result.ok) {
|
|
return NextResponse.json(
|
|
{ error: result.error, code: result.body?.kod },
|
|
{ status: result.status },
|
|
)
|
|
}
|
|
|
|
// Promote the matching declaration to 'exported'. salaryRunId is
|
|
// accepted in the body for the happy path; if missing we can still
|
|
// fall back to the locally-cached submission state, which always
|
|
// carries it (we wrote it in /agi/submit).
|
|
let runId = body.salaryRunId
|
|
if (!runId) {
|
|
// Last-resort lookup: scan recent agi_submission_* keys for a
|
|
// matching inlamningId. Cheap because there's at most one active
|
|
// submission per period and the operator typically has very few.
|
|
const { data: rows } = await ctx.supabase
|
|
.from('extension_data')
|
|
.select('value')
|
|
.eq('company_id', ctx.companyId)
|
|
.eq('extension_id', 'skatteverket')
|
|
.like('key', 'agi_submission_%')
|
|
for (const row of rows ?? []) {
|
|
try {
|
|
const v = JSON.parse(row.value as string) as { inlamningId?: number; salaryRunId?: string }
|
|
if (v.inlamningId === inlamningId && v.salaryRunId) {
|
|
runId = v.salaryRunId
|
|
break
|
|
}
|
|
} catch { /* skip malformed */ }
|
|
}
|
|
}
|
|
if (runId) {
|
|
// Monotonicity guard: only flip from a pre-filing state. If the
|
|
// kvittens cron or the interactive /agi/kvittenser handler has
|
|
// already promoted this row to 'submitted'/'accepted', don't
|
|
// regress it — behandlingshistorik must move forward through
|
|
// the filing milestones (BFNAR 2013:2 kap 8).
|
|
//
|
|
// 'rejected' IS allowed as an originating state: a previous
|
|
// submission failed kontrollresultat, the user fixed the XML
|
|
// and re-submitted. The same agi_declarations row is reused
|
|
// (xml-route updates xml_content in place), so this update
|
|
// promotes the recovered submission back to pending_signature.
|
|
//
|
|
// 'exported' is NOT in the allowed-from list. The status value
|
|
// is preserved in the schema for the legacy manual-download
|
|
// path (see migration), but no code currently writes it; an
|
|
// 'exported' row encountered here would represent a parallel
|
|
// filing attempt that should land in its own row, not reuse
|
|
// this one (preserves chain of custody per BFL 5 kap 6§).
|
|
await ctx.supabase
|
|
.from('agi_declarations')
|
|
.update({ status: 'pending_signature' })
|
|
.eq('salary_run_id', runId)
|
|
.eq('company_id', ctx.companyId)
|
|
.in('status', ['generated', 'rejected'])
|
|
}
|
|
|
|
return NextResponse.json({ data: result.data })
|
|
} catch (err) {
|
|
return handleSkvError(err)
|
|
}
|
|
},
|
|
},
|
|
|
|
// ── AGI: Avbryt underlag (before spara) ─────────────────────────
|
|
// Query: ?inlamningId=...&period=YYYYMM (period optional but recommended)
|
|
//
|
|
// The `period` param lets the handler clear the locally-cached
|
|
// `agi_submission_{period}` record so the UI doesn't sit on a stale
|
|
// `underlag_submitted` state. If the caller doesn't pass it we fall
|
|
// back to scanning recent submission keys for the matching inlamningId.
|
|
{
|
|
method: 'DELETE',
|
|
path: '/agi/underlag',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
if (!ctx) {
|
|
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
|
}
|
|
try {
|
|
const url = new URL(request.url)
|
|
const inlamningId = Number(url.searchParams.get('inlamningId'))
|
|
const period = url.searchParams.get('period')
|
|
if (!Number.isFinite(inlamningId) || inlamningId <= 0) {
|
|
return NextResponse.json({ error: 'Saknar parameter: inlamningId' }, { status: 400 })
|
|
}
|
|
const result = await agiAvbrytUnderlag(ctx.supabase, ctx.userId, inlamningId)
|
|
if (!result.ok) {
|
|
return NextResponse.json(
|
|
{ error: result.error, code: result.body?.kod },
|
|
{ status: result.status },
|
|
)
|
|
}
|
|
|
|
// Clear locally-cached submission state so the UI doesn't keep
|
|
// showing `underlag_submitted` for an inlamning that no longer
|
|
// exists at SKV. Direct path: caller passed period.
|
|
if (period) {
|
|
await ctx.settings.clear(`agi_submission_${period}`)
|
|
} else {
|
|
// Fallback: find the period by matching inlamningId across
|
|
// recent submission keys. Cheap because there's at most one
|
|
// active submission per period.
|
|
const { data: rows } = await ctx.supabase
|
|
.from('extension_data')
|
|
.select('key, value')
|
|
.eq('company_id', ctx.companyId)
|
|
.eq('extension_id', 'skatteverket')
|
|
.like('key', 'agi_submission_%')
|
|
for (const row of rows ?? []) {
|
|
try {
|
|
const v = JSON.parse(row.value as string) as { inlamningId?: number }
|
|
if (v.inlamningId === inlamningId) {
|
|
await ctx.supabase
|
|
.from('extension_data')
|
|
.delete()
|
|
.eq('company_id', ctx.companyId)
|
|
.eq('extension_id', 'skatteverket')
|
|
.eq('key', row.key as string)
|
|
break
|
|
}
|
|
} catch { /* skip malformed */ }
|
|
}
|
|
}
|
|
|
|
return NextResponse.json({ success: true })
|
|
} catch (err) {
|
|
return handleSkvError(err)
|
|
}
|
|
},
|
|
},
|
|
|
|
// ── AGI: Ta bort sparad inlämning (after spara) ─────────────────
|
|
// Query: ?arbetsgivare=...&period=YYYYMM&inlamningId=...
|
|
{
|
|
method: 'DELETE',
|
|
path: '/agi/sparad',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
if (!ctx) {
|
|
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
|
}
|
|
try {
|
|
const url = new URL(request.url)
|
|
const arbetsgivare = url.searchParams.get('arbetsgivare')
|
|
const period = url.searchParams.get('period')
|
|
const inlamningId = Number(url.searchParams.get('inlamningId'))
|
|
if (!arbetsgivare || !period || !Number.isFinite(inlamningId) || inlamningId <= 0) {
|
|
return NextResponse.json(
|
|
{ error: 'Saknar parametrar: arbetsgivare, period, inlamningId' },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
const result = await agiTaBortSparadInlamning(
|
|
ctx.supabase, ctx.userId, arbetsgivare, period, inlamningId,
|
|
)
|
|
if (!result.ok) {
|
|
return NextResponse.json(
|
|
{ error: result.error, code: result.body?.kod },
|
|
{ status: result.status },
|
|
)
|
|
}
|
|
await ctx.settings.clear(`agi_submission_${period}`)
|
|
return NextResponse.json({ success: true })
|
|
} catch (err) {
|
|
return handleSkvError(err)
|
|
}
|
|
},
|
|
},
|
|
|
|
// ── AGI: Skapa granskningsunderlag (BankID signing link) ────────
|
|
// Query: ?arbetsgivare=...&period=YYYYMM&lasPeriod=true|false
|
|
// Returns { link, tillstand, meddelande }. The user opens `link` in a
|
|
// new tab and signs with BankID on Skatteverket's site.
|
|
{
|
|
method: 'POST',
|
|
path: '/agi/granskningsunderlag',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
if (!ctx) {
|
|
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
|
}
|
|
try {
|
|
const url = new URL(request.url)
|
|
const arbetsgivare = url.searchParams.get('arbetsgivare')
|
|
const period = url.searchParams.get('period')
|
|
const lasPeriod = url.searchParams.get('lasPeriod') !== 'false' // default true
|
|
|
|
if (!arbetsgivare || !period) {
|
|
return NextResponse.json(
|
|
{ error: 'Saknar parametrar: arbetsgivare, period' },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
const result = await agiSkapaGranskningsunderlag(
|
|
ctx.supabase, ctx.userId, arbetsgivare, period, { lasPeriod },
|
|
)
|
|
if (!result.ok) {
|
|
return NextResponse.json(
|
|
{ error: result.error, code: result.body?.kod },
|
|
{ status: result.status },
|
|
)
|
|
}
|
|
|
|
// Persist the link so the user can return to it after a refresh.
|
|
// SKV's tillstand enum (per skapagranskningsunderlagsvar.json):
|
|
// LOCKED_FOR_SIGNING / UNLOCKED → granskning ready, user can sign
|
|
// INCORRECT_DATA → felrapport link, can't sign yet
|
|
// RECEIVING / CALCULATING → server still processing
|
|
// SIGNING → another signing flow already running
|
|
// We key on tillstand alone — keying on HTTP status (e.g. 409) and
|
|
// the body string would miss future SKV additions like RECEIVING
|
|
// returned with HTTP 200, leaving us in an awaiting_signing state
|
|
// when the underlag isn't actually ready.
|
|
const canSign =
|
|
result.data.tillstand === 'LOCKED_FOR_SIGNING' ||
|
|
result.data.tillstand === 'UNLOCKED'
|
|
await ctx.settings.set(
|
|
`agi_submission_${period}`,
|
|
JSON.stringify({
|
|
status: canSign ? 'awaiting_signing' : 'underlag_rejected',
|
|
arbetsgivare,
|
|
period,
|
|
signeringslank: result.data.link,
|
|
tillstand: result.data.tillstand,
|
|
meddelande: result.data.meddelande,
|
|
updatedAt: new Date().toISOString(),
|
|
}),
|
|
)
|
|
|
|
// Flip the matching agi_declarations row to pending_signature when
|
|
// SKV returns a usable granskningsunderlag. Previously this happened
|
|
// in /agi/spara, but /spara is only valid for re-saving rejected
|
|
// underlag — successful underlag are auto-persisted by SKV, so
|
|
// /spara returns felkod 20 on the happy path. Doing the flip here
|
|
// ensures the audit trail still moves through pending_signature
|
|
// → submitted (BFNAR 2013:2 kap 8 / BFL 5 kap 6§) without /spara.
|
|
if (canSign) {
|
|
const periodYear = parseInt(period.slice(0, 4))
|
|
const periodMonth = parseInt(period.slice(4, 6))
|
|
await ctx.supabase
|
|
.from('agi_declarations')
|
|
.update({ status: 'pending_signature' })
|
|
.eq('company_id', ctx.companyId)
|
|
.eq('period_year', periodYear)
|
|
.eq('period_month', periodMonth)
|
|
.in('status', ['generated', 'rejected'])
|
|
}
|
|
|
|
return NextResponse.json({ data: result.data })
|
|
} catch (err) {
|
|
return handleSkvError(err)
|
|
}
|
|
},
|
|
},
|
|
|
|
// ── AGI: Hämta kvittenser (after user signs) ────────────────────
|
|
// Query: ?arbetsgivare=...&period=YYYYMM
|
|
// Returns the kvittenser array. While the user has not yet signed the
|
|
// array is empty; after signing it carries uuidKvittens/signeradAv/-Tid.
|
|
{
|
|
method: 'GET',
|
|
path: '/agi/kvittenser',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
if (!ctx) {
|
|
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
|
}
|
|
try {
|
|
const url = new URL(request.url)
|
|
const arbetsgivare = url.searchParams.get('arbetsgivare')
|
|
const period = url.searchParams.get('period')
|
|
if (!arbetsgivare || !period) {
|
|
return NextResponse.json(
|
|
{ error: 'Saknar parametrar: arbetsgivare, period' },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
const result = await agiGetKvittenser(ctx.supabase, ctx.userId, arbetsgivare, period)
|
|
if (!result.ok) {
|
|
return NextResponse.json(
|
|
{ error: result.error, code: result.body?.kod },
|
|
{ status: result.status },
|
|
)
|
|
}
|
|
|
|
// Newest kvittens for the period drives the local state.
|
|
const kvittens = result.data.kvittenser?.[0]
|
|
if (kvittens?.uuidKvittens) {
|
|
const periodYear = parseInt(period.slice(0, 4))
|
|
const periodMonth = parseInt(period.slice(4, 6))
|
|
await ctx.settings.set(
|
|
`agi_submission_${period}`,
|
|
JSON.stringify({
|
|
status: 'signed',
|
|
arbetsgivare,
|
|
period,
|
|
kvittensnummer: kvittens.uuidKvittens,
|
|
signeradAv: kvittens.signeradAv,
|
|
signeradTid: kvittens.signeradTid,
|
|
updatedAt: new Date().toISOString(),
|
|
}),
|
|
)
|
|
|
|
// Pin the receipt to the most recent declaration for this period
|
|
// (id desc) so a correction chain doesn't get its kvittens written
|
|
// onto a superseded row. Also stamp salary_runs.agi_submitted_at
|
|
// here, mirroring SKV's signeradTid — this is the only place we
|
|
// know the AGI was actually filed (the orchestrator deliberately
|
|
// doesn't stamp on underlag-ingest, see route.ts comment).
|
|
//
|
|
// The presence of `uuidKvittens` confirms SKV signed and accepted
|
|
// the AGI. signeradTid is the precise signing moment; if SKV
|
|
// omits it we fall back to reconciliation time + warn so the
|
|
// discrepancy is investigable. Leaving NULL would hide that the
|
|
// filing occurred at all, which itself misstates the behandlings-
|
|
// historik (BFNAR 2013:2 kap 8 / BFL 5 kap 6§). The fallback
|
|
// applies only on this code path because we're inside the
|
|
// `if (kvittens?.uuidKvittens)` branch — if no kvittens, no stamp.
|
|
const submittedAt = kvittens.signeradTid || new Date().toISOString()
|
|
if (!kvittens.signeradTid) {
|
|
console.warn('[skatteverket] kvittens missing signeradTid; using reconciliation time', {
|
|
companyId: ctx.companyId, period, uuidKvittens: kvittens.uuidKvittens,
|
|
})
|
|
}
|
|
const { data: latest } = await ctx.supabase
|
|
.from('agi_declarations')
|
|
.select('id, salary_run_id')
|
|
.eq('company_id', ctx.companyId)
|
|
.eq('period_year', periodYear)
|
|
.eq('period_month', periodMonth)
|
|
.order('created_at', { ascending: false })
|
|
.limit(1)
|
|
.maybeSingle()
|
|
if (latest?.id) {
|
|
// submitted_by is the auth.users UUID we have on hand (the
|
|
// operator who polled the kvittens endpoint). The actual
|
|
// BankID signer is identified by kvittens.signeradAv (a
|
|
// personnummer string), which we preserve in response_data
|
|
// alongside the rest of the receipt — that's the legally
|
|
// load-bearing audit record per BFL 5 kap 6§.
|
|
await ctx.supabase
|
|
.from('agi_declarations')
|
|
.update({
|
|
status: 'submitted',
|
|
kvittensnummer: kvittens.uuidKvittens,
|
|
submitted_at: submittedAt,
|
|
submitted_by: ctx.userId,
|
|
response_data: {
|
|
signeradAv: kvittens.signeradAv ?? null,
|
|
signeradTid: kvittens.signeradTid ?? null,
|
|
uuidKvittens: kvittens.uuidKvittens,
|
|
arbetsgivare: kvittens.arbetsgivare ?? null,
|
|
period: kvittens.period ?? null,
|
|
underlag: kvittens.underlag ?? null,
|
|
},
|
|
})
|
|
.eq('id', latest.id)
|
|
|
|
if (latest.salary_run_id) {
|
|
await ctx.supabase
|
|
.from('salary_runs')
|
|
.update({ agi_submitted_at: submittedAt })
|
|
.eq('id', latest.salary_run_id)
|
|
.eq('company_id', ctx.companyId)
|
|
}
|
|
}
|
|
}
|
|
|
|
return NextResponse.json({ data: result.data })
|
|
} catch (err) {
|
|
return handleSkvError(err)
|
|
}
|
|
},
|
|
},
|
|
|
|
// ── AGI: Pre-flight kontrollera HU/IU ───────────────────────────
|
|
// Validate a single HU or IU as JSON without saving anything in
|
|
// Skatteverket. Lets the UI catch errors per HU/IU before the user
|
|
// submits a full XML underlag. Body: the HU or IU as JSON per the
|
|
// v1.7 spec §7 / §8 (property names like agRegistreradId,
|
|
// redovisningsPeriod, kontantErsattningUlagAG, …).
|
|
//
|
|
// Response shape (kontrollsvar):
|
|
// { status: 'OK' | 'INFO' | 'ARENDE' | 'STOPP' | 'AVVISANDE',
|
|
// fel: [{ status, felmeddelande }, …] }
|
|
{
|
|
method: 'POST',
|
|
path: '/agi/kontrollera/hu',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
if (!ctx) {
|
|
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
|
}
|
|
const rbac = await requireAgiWriteRole(ctx)
|
|
if (rbac) return rbac
|
|
|
|
// Read body as bytes first so we can enforce a hard size cap before
|
|
// JSON.parse — protects against ~MB payloads that would otherwise
|
|
// hit the Zod validator.
|
|
const rawBytes = Buffer.byteLength(await request.clone().text(), 'utf8')
|
|
if (rawBytes > AGI_KONTROLLERA_MAX_BYTES) {
|
|
await writeSkatteverketAudit(ctx, {
|
|
endpoint: 'agi.kontrollera.hu',
|
|
outcome: 'validation_error',
|
|
responseStatus: 413,
|
|
requestSizeBytes: rawBytes,
|
|
errorMessage: 'payload exceeds 64KB cap',
|
|
})
|
|
return NextResponse.json(
|
|
{ error: 'Payload överstiger maxstorleken för pre-flight kontroll (64 KB).' },
|
|
{ status: 413 },
|
|
)
|
|
}
|
|
|
|
let parsedBody: unknown
|
|
try {
|
|
parsedBody = await request.json()
|
|
} catch {
|
|
await writeSkatteverketAudit(ctx, {
|
|
endpoint: 'agi.kontrollera.hu',
|
|
outcome: 'validation_error',
|
|
responseStatus: 400,
|
|
requestSizeBytes: rawBytes,
|
|
errorMessage: 'invalid JSON',
|
|
})
|
|
return NextResponse.json({ error: 'Ogiltig JSON i förfrågan.' }, { status: 400 })
|
|
}
|
|
|
|
const parsed = AGIKontrolleraHUSchema.safeParse(parsedBody)
|
|
if (!parsed.success) {
|
|
await writeSkatteverketAudit(ctx, {
|
|
endpoint: 'agi.kontrollera.hu',
|
|
outcome: 'validation_error',
|
|
responseStatus: 400,
|
|
requestSizeBytes: rawBytes,
|
|
errorMessage: parsed.error.issues
|
|
.map((iss) => `${iss.path.join('.')}: ${iss.message}`)
|
|
.join('; ')
|
|
.slice(0, 1000),
|
|
})
|
|
return NextResponse.json(
|
|
{
|
|
error: 'HU-payload matchar inte Skatteverkets v1.7 §7-schema.',
|
|
type: 'validation_error',
|
|
errors: parsed.error.issues.map((iss) => ({
|
|
field: iss.path.join('.'),
|
|
message: iss.message,
|
|
code: iss.code,
|
|
})),
|
|
},
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
try {
|
|
const result = await agiKontrolleraHU(ctx.supabase, ctx.userId, parsed.data)
|
|
if (!result.ok) {
|
|
await writeSkatteverketAudit(ctx, {
|
|
endpoint: 'agi.kontrollera.hu',
|
|
agRegistreradId: parsed.data.agRegistreradId,
|
|
redovisningsperiod: parsed.data.redovisningsPeriod,
|
|
outcome: result.status === 401 || result.status === 403 ? 'auth_error' : 'skv_error',
|
|
responseStatus: result.status,
|
|
requestSizeBytes: rawBytes,
|
|
errorMessage: result.error,
|
|
})
|
|
return NextResponse.json(
|
|
{ error: result.error, code: result.body?.kod },
|
|
{ status: result.status },
|
|
)
|
|
}
|
|
await writeSkatteverketAudit(ctx, {
|
|
endpoint: 'agi.kontrollera.hu',
|
|
agRegistreradId: parsed.data.agRegistreradId,
|
|
redovisningsperiod: parsed.data.redovisningsPeriod,
|
|
outcome: 'ok',
|
|
responseStatus: result.status,
|
|
skvStatus: result.data?.status ?? null,
|
|
requestSizeBytes: rawBytes,
|
|
})
|
|
return NextResponse.json({ data: result.data })
|
|
} catch (err) {
|
|
await writeSkatteverketAudit(ctx, {
|
|
endpoint: 'agi.kontrollera.hu',
|
|
agRegistreradId: parsed.data.agRegistreradId,
|
|
redovisningsperiod: parsed.data.redovisningsPeriod,
|
|
outcome: 'internal_error',
|
|
requestSizeBytes: rawBytes,
|
|
errorMessage: err instanceof Error ? err.message : String(err),
|
|
})
|
|
return handleSkvError(err)
|
|
}
|
|
},
|
|
},
|
|
|
|
{
|
|
method: 'POST',
|
|
path: '/agi/kontrollera/iu',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
if (!ctx) {
|
|
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
|
}
|
|
const rbac = await requireAgiWriteRole(ctx)
|
|
if (rbac) return rbac
|
|
|
|
const rawBytes = Buffer.byteLength(await request.clone().text(), 'utf8')
|
|
if (rawBytes > AGI_KONTROLLERA_MAX_BYTES) {
|
|
await writeSkatteverketAudit(ctx, {
|
|
endpoint: 'agi.kontrollera.iu',
|
|
outcome: 'validation_error',
|
|
responseStatus: 413,
|
|
requestSizeBytes: rawBytes,
|
|
errorMessage: 'payload exceeds 64KB cap',
|
|
})
|
|
return NextResponse.json(
|
|
{ error: 'Payload överstiger maxstorleken för pre-flight kontroll (64 KB).' },
|
|
{ status: 413 },
|
|
)
|
|
}
|
|
|
|
let parsedBody: unknown
|
|
try {
|
|
parsedBody = await request.json()
|
|
} catch {
|
|
await writeSkatteverketAudit(ctx, {
|
|
endpoint: 'agi.kontrollera.iu',
|
|
outcome: 'validation_error',
|
|
responseStatus: 400,
|
|
requestSizeBytes: rawBytes,
|
|
errorMessage: 'invalid JSON',
|
|
})
|
|
return NextResponse.json({ error: 'Ogiltig JSON i förfrågan.' }, { status: 400 })
|
|
}
|
|
|
|
const parsed = AGIKontrolleraIUSchema.safeParse(parsedBody)
|
|
if (!parsed.success) {
|
|
await writeSkatteverketAudit(ctx, {
|
|
endpoint: 'agi.kontrollera.iu',
|
|
outcome: 'validation_error',
|
|
responseStatus: 400,
|
|
requestSizeBytes: rawBytes,
|
|
errorMessage: parsed.error.issues
|
|
.map((iss) => `${iss.path.join('.')}: ${iss.message}`)
|
|
.join('; ')
|
|
.slice(0, 1000),
|
|
})
|
|
return NextResponse.json(
|
|
{
|
|
error: 'IU-payload matchar inte Skatteverkets v1.7 §8-schema.',
|
|
type: 'validation_error',
|
|
errors: parsed.error.issues.map((iss) => ({
|
|
field: iss.path.join('.'),
|
|
message: iss.message,
|
|
code: iss.code,
|
|
})),
|
|
},
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
try {
|
|
const result = await agiKontrolleraIU(ctx.supabase, ctx.userId, parsed.data)
|
|
if (!result.ok) {
|
|
await writeSkatteverketAudit(ctx, {
|
|
endpoint: 'agi.kontrollera.iu',
|
|
agRegistreradId: parsed.data.agRegistreradId,
|
|
redovisningsperiod: parsed.data.redovisningsPeriod,
|
|
outcome: result.status === 401 || result.status === 403 ? 'auth_error' : 'skv_error',
|
|
responseStatus: result.status,
|
|
requestSizeBytes: rawBytes,
|
|
errorMessage: result.error,
|
|
})
|
|
return NextResponse.json(
|
|
{ error: result.error, code: result.body?.kod },
|
|
{ status: result.status },
|
|
)
|
|
}
|
|
await writeSkatteverketAudit(ctx, {
|
|
endpoint: 'agi.kontrollera.iu',
|
|
agRegistreradId: parsed.data.agRegistreradId,
|
|
redovisningsperiod: parsed.data.redovisningsPeriod,
|
|
outcome: 'ok',
|
|
responseStatus: result.status,
|
|
skvStatus: result.data?.status ?? null,
|
|
requestSizeBytes: rawBytes,
|
|
})
|
|
return NextResponse.json({ data: result.data })
|
|
} catch (err) {
|
|
await writeSkatteverketAudit(ctx, {
|
|
endpoint: 'agi.kontrollera.iu',
|
|
agRegistreradId: parsed.data.agRegistreradId,
|
|
redovisningsperiod: parsed.data.redovisningsPeriod,
|
|
outcome: 'internal_error',
|
|
requestSizeBytes: rawBytes,
|
|
errorMessage: err instanceof Error ? err.message : String(err),
|
|
})
|
|
return handleSkvError(err)
|
|
}
|
|
},
|
|
},
|
|
|
|
// ── AGI: Lås period ─────────────────────────────────────────────
|
|
// Hantera-API; typically not needed (skapaGranskningsunderlag already
|
|
// accepts lasPeriod=true). Exposed for recovery / manual control.
|
|
{
|
|
method: 'POST',
|
|
path: '/agi/las',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
if (!ctx) return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
|
try {
|
|
const url = new URL(request.url)
|
|
const arbetsgivare = url.searchParams.get('arbetsgivare')
|
|
const period = url.searchParams.get('period')
|
|
if (!arbetsgivare || !period) {
|
|
return NextResponse.json(
|
|
{ error: 'Saknar parametrar: arbetsgivare, period' },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
const result = await agiLasPeriod(ctx.supabase, ctx.userId, arbetsgivare, period)
|
|
if (!result.ok) {
|
|
return NextResponse.json(
|
|
{ error: result.error, code: result.body?.kod },
|
|
{ status: result.status },
|
|
)
|
|
}
|
|
return NextResponse.json({ data: result.data })
|
|
} catch (err) {
|
|
return handleSkvError(err)
|
|
}
|
|
},
|
|
},
|
|
|
|
// ── AGI: Lås upp period ─────────────────────────────────────────
|
|
{
|
|
method: 'POST',
|
|
path: '/agi/lasUpp',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
if (!ctx) return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
|
try {
|
|
const url = new URL(request.url)
|
|
const arbetsgivare = url.searchParams.get('arbetsgivare')
|
|
const period = url.searchParams.get('period')
|
|
if (!arbetsgivare || !period) {
|
|
return NextResponse.json(
|
|
{ error: 'Saknar parametrar: arbetsgivare, period' },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
const result = await agiLasUppPeriod(ctx.supabase, ctx.userId, arbetsgivare, period)
|
|
if (!result.ok) {
|
|
return NextResponse.json(
|
|
{ error: result.error, code: result.body?.kod },
|
|
{ status: result.status },
|
|
)
|
|
}
|
|
// Unlocking abandons the granskningsunderlag, so the locally-cached
|
|
// `awaiting_signing` record no longer reflects SKV — the signing link
|
|
// it carries points at a released draft. Clear it (mirroring the
|
|
// DELETE /agi/underlag and /agi/sparad handlers) so the panel drops
|
|
// back to the pre-submission state instead of stranding the user on a
|
|
// stale "redo att signeras" box.
|
|
await ctx.settings.clear(`agi_submission_${period}`)
|
|
return NextResponse.json({ data: result.data })
|
|
} catch (err) {
|
|
return handleSkvError(err)
|
|
}
|
|
},
|
|
},
|
|
|
|
// ── AGI: Local submission tracking (UI helper) ──────────────────
|
|
// Returns the locally-cached submission state (inlamningId, signing link,
|
|
// kvittensnummer if seen). Pure read; never calls Skatteverket.
|
|
{
|
|
method: 'GET',
|
|
path: '/agi/status',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
if (!ctx) return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
|
const url = new URL(request.url)
|
|
const period = url.searchParams.get('period')
|
|
if (!period) return NextResponse.json({ error: 'Saknar parameter: period' }, { status: 400 })
|
|
|
|
const statusJson = await ctx.settings.get<string>(`agi_submission_${period}`)
|
|
if (!statusJson) return NextResponse.json({ data: null })
|
|
try {
|
|
return NextResponse.json({ data: JSON.parse(statusJson) })
|
|
} catch {
|
|
return NextResponse.json({ data: null })
|
|
}
|
|
},
|
|
},
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// Skattekonto routes (read-only balance + transactions)
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
// ── Saldo (cached snapshot) ────────────────────────────────────
|
|
// Returns the most recent saldoResponse cached in extension_data.
|
|
// The dashboard uses this for repeated renders without hitting SKV.
|
|
// Force a refresh by calling POST /skattekonto/sync first.
|
|
{
|
|
method: 'GET',
|
|
path: '/skattekonto/saldo',
|
|
handler: async (_request: Request, ctx?: ExtensionContext) => {
|
|
if (!ctx) {
|
|
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
|
}
|
|
const snapshot = await ctx.settings.get<SkattekontoBalanceSnapshot>(SKATTEKONTO_BALANCE_SNAPSHOT_KEY)
|
|
const lastSyncedAt = await ctx.settings.get<string>(SKATTEKONTO_LAST_SYNCED_AT_KEY)
|
|
return NextResponse.json({
|
|
data: snapshot?.saldo ?? null,
|
|
fetchedAt: snapshot ? new Date(snapshot.fetchedAt).toISOString() : null,
|
|
lastSyncedAt: lastSyncedAt ?? null,
|
|
})
|
|
},
|
|
},
|
|
|
|
// ── Transaktioner (from local table) ───────────────────────────
|
|
// Returns booked + upcoming transactions for the active company.
|
|
// Optional `from` query filters tidigare on transaktionsdatum >= from.
|
|
{
|
|
method: 'GET',
|
|
path: '/skattekonto/transaktioner',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
if (!ctx) {
|
|
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
|
}
|
|
const url = new URL(request.url)
|
|
const from = url.searchParams.get('from')
|
|
|
|
let query = ctx.supabase
|
|
.from('skattekonto_transactions')
|
|
.select('*')
|
|
.eq('company_id', ctx.companyId)
|
|
.order('transaktionsdatum', { ascending: false })
|
|
|
|
if (from) query = query.gte('transaktionsdatum', from)
|
|
|
|
const { data, error } = await query
|
|
if (error) {
|
|
return NextResponse.json({ error: error.message }, { status: 500 })
|
|
}
|
|
|
|
const rows = data ?? []
|
|
const today = new Date().toISOString().slice(0, 10)
|
|
const { booked, overdue, upcoming } = splitTransactions(rows, today)
|
|
|
|
// Enrich obokförda rader with a single-best-candidate suggestion.
|
|
// Only attached when there's exactly one match — avoids the UI
|
|
// confidently pointing at the wrong verifikat.
|
|
const suggestions = await findMatchSuggestionsBulk(
|
|
ctx.supabase,
|
|
ctx.companyId,
|
|
booked.map(r => ({
|
|
id: r.id,
|
|
transaktionsdatum: r.transaktionsdatum,
|
|
belopp_skatteverket: Number(r.belopp_skatteverket),
|
|
journal_entry_id: r.journal_entry_id,
|
|
})),
|
|
)
|
|
|
|
const bookedEnriched = booked.map(r => ({
|
|
...r,
|
|
match_suggestion: suggestions.get(r.id) ?? null,
|
|
}))
|
|
|
|
return NextResponse.json({
|
|
data: {
|
|
booked: bookedEnriched,
|
|
overdue,
|
|
upcoming,
|
|
},
|
|
})
|
|
},
|
|
},
|
|
|
|
// ── Manual sync ────────────────────────────────────────────────
|
|
// Pulls fresh saldo + transactions from Skatteverket and upserts.
|
|
{
|
|
method: 'POST',
|
|
path: '/skattekonto/sync',
|
|
handler: async (_request: Request, ctx?: ExtensionContext) => {
|
|
if (!ctx) {
|
|
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
|
}
|
|
try {
|
|
const result = await syncSkattekonto(ctx)
|
|
return NextResponse.json({ data: result })
|
|
} catch (err) {
|
|
return handleSkvError(err)
|
|
}
|
|
},
|
|
},
|
|
|
|
// ── Bokför one row → draft journal entry ──────────────────────
|
|
// Creates a DRAFT verifikat in /bookkeeping for the user to review
|
|
// and commit. The skattekonto_transactions row is linked via
|
|
// journal_entry_id so the UI can show "Bokförd" status.
|
|
{
|
|
method: 'POST',
|
|
path: '/skattekonto/transaktioner/:id/bokfor',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
if (!ctx) {
|
|
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
|
}
|
|
|
|
// Extract :id from the catch-all dispatcher's path-param convention
|
|
// (`_id` query string, set in app/api/extensions/ext/[...path]/route.ts).
|
|
const url = new URL(request.url)
|
|
const id = url.searchParams.get('_id')
|
|
if (!id) {
|
|
return NextResponse.json({ error: 'Saknar transaktions-id' }, { status: 400 })
|
|
}
|
|
|
|
try {
|
|
const entry = await bokforSkattekontoTransaction(
|
|
ctx.supabase,
|
|
ctx.companyId,
|
|
ctx.userId,
|
|
id,
|
|
)
|
|
return NextResponse.json({ data: { entry } })
|
|
} catch (err) {
|
|
if (err instanceof SkattekontoBookingError) {
|
|
const status =
|
|
err.code === 'TRANSACTION_NOT_FOUND' ? 404
|
|
: err.code === 'ALREADY_BOOKED' ? 409
|
|
: err.code === 'PERIOD_LOCKED' ? 423
|
|
: err.code === 'NO_COUNTER_ACCOUNT' ? 422
|
|
: 400
|
|
return NextResponse.json(
|
|
{ error: err.message, code: err.code },
|
|
{ status },
|
|
)
|
|
}
|
|
return handleSkvError(err)
|
|
}
|
|
},
|
|
},
|
|
|
|
// ── Matcha mot befintligt verifikat ──────────────────────────────
|
|
// List candidate journal entries already touching 1630 with the right
|
|
// amount/side near the transaction date. Lets the user link the SKV
|
|
// row to a manually-booked bank transfer instead of creating a duplicate
|
|
// verifikat. Returns at most 25 candidates.
|
|
{
|
|
method: 'GET',
|
|
path: '/skattekonto/transaktioner/:id/match-candidates',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
if (!ctx) {
|
|
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
|
}
|
|
const url = new URL(request.url)
|
|
const id = url.searchParams.get('_id')
|
|
if (!id) {
|
|
return NextResponse.json({ error: 'Saknar transaktions-id' }, { status: 400 })
|
|
}
|
|
try {
|
|
const { candidates } = await findMatchCandidates(ctx.supabase, ctx.companyId, id)
|
|
return NextResponse.json({ data: { candidates } })
|
|
} catch (err) {
|
|
if (err instanceof SkattekontoMatchError) {
|
|
const status =
|
|
err.code === 'TRANSACTION_NOT_FOUND' ? 404
|
|
: err.code === 'ALREADY_BOOKED' ? 409
|
|
: 400
|
|
return NextResponse.json({ error: err.message, code: err.code }, { status })
|
|
}
|
|
return handleSkvError(err)
|
|
}
|
|
},
|
|
},
|
|
|
|
// Link the SKV row to a chosen candidate. No new verifikat is created
|
|
// — we just write journal_entry_id onto skattekonto_transactions. The
|
|
// candidate is re-validated server-side (matching 1630 line, not already
|
|
// linked) to catch races and a malicious client.
|
|
{
|
|
method: 'POST',
|
|
path: '/skattekonto/transaktioner/:id/match',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
if (!ctx) {
|
|
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
|
}
|
|
const url = new URL(request.url)
|
|
const id = url.searchParams.get('_id')
|
|
if (!id) {
|
|
return NextResponse.json({ error: 'Saknar transaktions-id' }, { status: 400 })
|
|
}
|
|
let body: { journal_entry_id?: string }
|
|
try {
|
|
body = (await request.json()) as { journal_entry_id?: string }
|
|
} catch {
|
|
return NextResponse.json({ error: 'Ogiltig request body' }, { status: 400 })
|
|
}
|
|
if (!body.journal_entry_id || typeof body.journal_entry_id !== 'string') {
|
|
return NextResponse.json(
|
|
{ error: 'Saknar journal_entry_id' },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
try {
|
|
await matchSkattekontoToEntry(
|
|
ctx.supabase,
|
|
ctx.companyId,
|
|
id,
|
|
body.journal_entry_id,
|
|
)
|
|
return NextResponse.json({ data: { ok: true } })
|
|
} catch (err) {
|
|
if (err instanceof SkattekontoMatchError) {
|
|
const status =
|
|
err.code === 'TRANSACTION_NOT_FOUND' ? 404
|
|
: err.code === 'ENTRY_NOT_FOUND' ? 404
|
|
: err.code === 'ALREADY_BOOKED' ? 409
|
|
: err.code === 'ENTRY_ALREADY_LINKED' ? 409
|
|
: 422
|
|
return NextResponse.json({ error: err.message, code: err.code }, { status })
|
|
}
|
|
return handleSkvError(err)
|
|
}
|
|
},
|
|
},
|
|
],
|
|
|
|
eventHandlers: [
|
|
{
|
|
eventType: 'skattekonto.drift_detected',
|
|
handler: handleSkattekontoDriftDetected,
|
|
},
|
|
],
|
|
|
|
// Registry-resolved commit services for the MCP submit tools. The core
|
|
// pending-operations dispatcher (lib/pending-operations/commit.ts) cannot
|
|
// import this extension (CI guard), so it reaches these through
|
|
// extensionRegistry.get('skatteverket')?.services when committing a staged
|
|
// submit_vat_declaration / submit_agi operation. Commit = "send for BankID
|
|
// signing" (returns a signing link), never "file".
|
|
services: {
|
|
commitSubmitVatDeclaration,
|
|
commitSubmitAgi,
|
|
},
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Parse and validate declaration request body, then compute the momsuppgift.
|
|
*
|
|
* The computation itself lives in lib/declaration-prep.ts (buildMomsuppgift)
|
|
* so the commit-side service and MCP tools file exactly the same numbers this
|
|
* route does — see the no-drift note there. This shell only parses the body.
|
|
*/
|
|
async function parseDeclarationRequest(
|
|
request: Request,
|
|
ctx: ExtensionContext
|
|
): Promise<VatDeclarationPrep> {
|
|
const body = await request.json()
|
|
const { periodType, year, period } = body as {
|
|
periodType: VatPeriodType
|
|
year: number
|
|
period: number
|
|
}
|
|
|
|
if (!periodType || !year || !period) {
|
|
throw new Error('Saknar obligatoriska fält: periodType, year, period')
|
|
}
|
|
|
|
return buildMomsuppgift(ctx.supabase, ctx.companyId, { periodType, year, period })
|
|
}
|
|
|
|
/**
|
|
* Parse redovisare and redovisningsperiod from query params.
|
|
* Used by GET/PUT/DELETE endpoints that don't need a full body.
|
|
*/
|
|
function parseQueryParams(
|
|
request: Request,
|
|
ctx: ExtensionContext
|
|
): { redovisare: string; redovisningsperiod: string } {
|
|
const url = new URL(request.url)
|
|
const redovisare = url.searchParams.get('redovisare')
|
|
const redovisningsperiod = url.searchParams.get('redovisningsperiod')
|
|
|
|
if (!redovisare || !redovisningsperiod) {
|
|
throw new Error('Saknar obligatoriska parametrar: redovisare, redovisningsperiod')
|
|
}
|
|
|
|
// Suppress unused variable warning — ctx is required by the type signature
|
|
void ctx
|
|
|
|
return { redovisare, redovisningsperiod }
|
|
}
|
|
|
|
/**
|
|
* Load the AGI XML for a salary run from agi_declarations.xml_content
|
|
* (built by app/api/salary/runs/[id]/agi/xml/route.ts via generateAGIXml).
|
|
*
|
|
* The lookup + status guard live in lib/declaration-prep.ts (buildAgiUnderlag)
|
|
* so the commit-side service files the same XML this route does. This shell
|
|
* only parses the salaryRunId from the body.
|
|
*/
|
|
async function loadAGIXml(
|
|
request: Request,
|
|
ctx: ExtensionContext,
|
|
): Promise<AgiUnderlagPrep> {
|
|
const body = (await request.json()) as { salaryRunId?: string }
|
|
return buildAgiUnderlag(ctx.supabase, ctx.companyId, body.salaryRunId ?? '')
|
|
}
|
|
|
|
/**
|
|
* Convert Skatteverket errors to appropriate HTTP responses.
|
|
*/
|
|
function handleSkvError(err: unknown): NextResponse {
|
|
if (err instanceof SkatteverketAuthError) {
|
|
// MISSING_SCOPE returns 401 — the existing token works, but it doesn't
|
|
// grant access to this resource. Treating it as 401 (rather than 403)
|
|
// signals to the frontend that the right remediation is to reconnect,
|
|
// not to ask the user to gain new authorization at SKV.
|
|
const status = err.code === 'NOT_CONNECTED' ? 401
|
|
: err.code === 'BEHORIGHET_SAKNAS' ? 403
|
|
: err.code === 'SESSION_EXPIRED' || err.code === 'REFRESH_EXHAUSTED' ? 401
|
|
: err.code === 'MISSING_SCOPE' ? 401
|
|
: err.code === 'TOKEN_CORRUPTED' ? 401
|
|
: err.code === 'TOKEN_REVOKED' ? 401
|
|
: 403
|
|
|
|
return NextResponse.json(
|
|
{ error: err.message, code: err.code },
|
|
{ status }
|
|
)
|
|
}
|
|
|
|
console.error('[skatteverket] API error:', err)
|
|
return NextResponse.json(
|
|
{ error: err instanceof Error ? err.message : 'Okänt fel' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
|
|
// ── MCP submit commit services ─────────────────────────────────────────
|
|
//
|
|
// Registry-resolved by lib/pending-operations/commit.ts when a staged
|
|
// submit_vat_declaration / submit_agi op is approved. "Commit" runs the SKV
|
|
// chain up to the BankID signing link and returns it — the user's signature in
|
|
// the browser is the irreversible filing act, outside this code.
|
|
//
|
|
// Direct lib calls bypass the HTTP dispatcher's SKATTEVERKET_ENABLED gate
|
|
// (app/api/extensions/ext/[...path]/route.ts), so each service checks the flag
|
|
// itself and returns a recoverable EXTENSION_DISABLED result (the op stays
|
|
// reviewable). SkatteverketAuthError (no connection / scope / quota) is
|
|
// likewise recoverable. SKV business rejections are non-recoverable → the op
|
|
// is consumed and the user regenerates + re-stages.
|
|
|
|
function skatteverketEnabled(): boolean {
|
|
return process.env.SKATTEVERKET_ENABLED === 'true'
|
|
}
|
|
|
|
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
|
|
|
|
const EXTENSION_DISABLED_RESULT: Extract<SkvSubmitResult, { ok: false }> = {
|
|
ok: false,
|
|
code: 'EXTENSION_DISABLED',
|
|
http_status: 503,
|
|
recoverable: true,
|
|
error: 'Skatteverket-integrationen är inte aktiverad i denna miljö.',
|
|
}
|
|
|
|
/**
|
|
* Translate a thrown error inside a commit service to a SkvSubmitResult.
|
|
* SkatteverketAuthError (connection / scope / quota) is recoverable — the op
|
|
* stays reviewable so the user reconnects and re-approves. Anything else is a
|
|
* non-recoverable internal error → the op is rejected.
|
|
*/
|
|
async function mapServiceError(
|
|
ctx: ExtensionContext,
|
|
endpoint: string,
|
|
err: unknown,
|
|
): Promise<Extract<SkvSubmitResult, { ok: false }>> {
|
|
if (err instanceof SkatteverketAuthError) {
|
|
const mapped = skvAuthCodeToStructured(err.code)
|
|
await writeSkatteverketAudit(ctx, { endpoint, outcome: 'auth_error', errorMessage: err.message })
|
|
return { ok: false, code: mapped.code, http_status: mapped.httpStatus, recoverable: true, error: err.message }
|
|
}
|
|
await writeSkatteverketAudit(ctx, {
|
|
endpoint,
|
|
outcome: 'internal_error',
|
|
errorMessage: err instanceof Error ? err.message : String(err),
|
|
})
|
|
return {
|
|
ok: false,
|
|
code: 'SKATTEVERKET_INTERNAL_ERROR',
|
|
http_status: 500,
|
|
recoverable: false,
|
|
error: err instanceof Error ? err.message : 'Okänt fel',
|
|
}
|
|
}
|
|
|
|
/**
|
|
* VAT "skicka för signering": POST /utkast + PUT /las → signeringslänk.
|
|
* Recompute-at-commit (buildMomsuppgift over posted entries) so the figures
|
|
* filed equal what the preview showed for the same ledger state.
|
|
*/
|
|
async function commitSubmitVatDeclaration(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
companyId: string,
|
|
params: Record<string, unknown>,
|
|
): Promise<SkvSubmitResult> {
|
|
if (!skatteverketEnabled()) return EXTENSION_DISABLED_RESULT
|
|
|
|
const periodType = params.period_type as VatPeriodType
|
|
const year = params.year as number
|
|
const period = params.period as number
|
|
const ctx = createExtensionContext(supabase, userId, companyId, 'skatteverket')
|
|
|
|
try {
|
|
const { redovisare, redovisningsperiod, momsuppgift } =
|
|
await buildMomsuppgift(supabase, companyId, { periodType, year, period })
|
|
|
|
// 1. POST /utkast — save the draft to Eget utrymme. Overwrites any prior
|
|
// draft for the period, so retry after a mid-chain failure is safe.
|
|
const utkast = await skvRequest(
|
|
supabase, userId, 'POST', `/utkast/${redovisare}/${redovisningsperiod}`, momsuppgift,
|
|
)
|
|
await writeSkatteverketAudit(ctx, {
|
|
endpoint: 'declaration/draft', agRegistreradId: redovisare, redovisningsperiod,
|
|
outcome: utkast.ok ? 'ok' : 'skv_error', responseStatus: utkast.status,
|
|
})
|
|
if (!utkast.ok) {
|
|
const text = await utkast.text().catch(() => '')
|
|
return {
|
|
ok: false, code: 'SKATTEVERKET_SUBMIT_REJECTED', http_status: utkast.status,
|
|
recoverable: false, error: `Skatteverket svarade med ${utkast.status}: ${text}`,
|
|
}
|
|
}
|
|
const utkastData = (await utkast.json()) as SkatteverketUtkastResponse
|
|
|
|
// 2. PUT /las — lock for signing; returns the BankID signeringslänk.
|
|
const las = await skvRequest(
|
|
supabase, userId, 'PUT', `/las/${redovisare}/${redovisningsperiod}`,
|
|
)
|
|
await writeSkatteverketAudit(ctx, {
|
|
endpoint: 'declaration/lock', agRegistreradId: redovisare, redovisningsperiod,
|
|
outcome: las.ok ? 'ok' : 'skv_error', responseStatus: las.status,
|
|
})
|
|
if (!las.ok) {
|
|
const text = await las.text().catch(() => '')
|
|
return {
|
|
ok: false, code: 'SKATTEVERKET_SUBMIT_REJECTED', http_status: las.status,
|
|
recoverable: false, error: `Skatteverket svarade med ${las.status}: ${text}`,
|
|
}
|
|
}
|
|
const lasData = (await las.json()) as SkatteverketUtkastResponse
|
|
if (!lasData.signeringsLank) {
|
|
return {
|
|
ok: false, code: 'SKATTEVERKET_SUBMIT_REJECTED', http_status: 502, recoverable: false,
|
|
error: 'Skatteverket låste deklarationen men returnerade ingen signeringslänk.',
|
|
}
|
|
}
|
|
|
|
// Persist locked state so the UI/poller can resume (mirrors /declaration/lock).
|
|
await ctx.settings.set(
|
|
`submission_${redovisningsperiod}`,
|
|
JSON.stringify({
|
|
status: 'draft_locked', redovisare, redovisningsperiod,
|
|
signeringsLank: lasData.signeringsLank, updatedAt: new Date().toISOString(),
|
|
}),
|
|
)
|
|
|
|
return {
|
|
ok: true,
|
|
signing_url: lasData.signeringsLank,
|
|
redovisningsperiod,
|
|
redovisare,
|
|
kontrollresultat: utkastData.kontrollResultat ?? null,
|
|
}
|
|
} catch (err) {
|
|
return mapServiceError(ctx, 'declaration/submit', err)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* AGI "skicka för signering": POST /underlag → poll kontrollresultat →
|
|
* skapaGranskningsunderlag(lasPeriod) → Mina Sidor signing link. Mirrors the
|
|
* route handlers' status flips (monotonic guards) and audit rows.
|
|
*/
|
|
async function commitSubmitAgi(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
companyId: string,
|
|
params: Record<string, unknown>,
|
|
): Promise<SkvSubmitResult> {
|
|
if (!skatteverketEnabled()) return EXTENSION_DISABLED_RESULT
|
|
|
|
const salaryRunId = params.salary_run_id as string
|
|
const ctx = createExtensionContext(supabase, userId, companyId, 'skatteverket')
|
|
|
|
try {
|
|
const { arbetsgivare, period, xml, periodYear, periodMonth } =
|
|
await buildAgiUnderlag(supabase, companyId, salaryRunId)
|
|
|
|
// 1. POST /underlag (XML) → inlamningId.
|
|
const submit = await agiPostUnderlag(supabase, userId, xml)
|
|
await writeSkatteverketAudit(ctx, {
|
|
endpoint: 'agi/submit', agRegistreradId: arbetsgivare, redovisningsperiod: period,
|
|
outcome: submit.ok ? 'ok' : 'skv_error', responseStatus: submit.status,
|
|
errorMessage: submit.ok ? null : submit.error,
|
|
})
|
|
if (!submit.ok) {
|
|
return { ok: false, code: 'SKATTEVERKET_SUBMIT_REJECTED', http_status: submit.status,
|
|
recoverable: false, error: submit.error }
|
|
}
|
|
const inlamningId = submit.data.inlamningId
|
|
await ctx.settings.set(`agi_submission_${period}`, JSON.stringify({
|
|
status: 'underlag_submitted', arbetsgivare, period, salaryRunId,
|
|
inlamningId, updatedAt: new Date().toISOString(),
|
|
}))
|
|
|
|
// 2. Poll kontrollresultat (bounded; SKV is typically sub-second).
|
|
let kontroll = await agiGetKontrollresultat(supabase, userId, inlamningId)
|
|
for (let i = 0; i < 2 && kontroll.ok && kontroll.data.status === 'PROCESSING'; i++) {
|
|
await sleep(750)
|
|
kontroll = await agiGetKontrollresultat(supabase, userId, inlamningId)
|
|
}
|
|
await writeSkatteverketAudit(ctx, {
|
|
endpoint: 'agi/kontrollresultat', agRegistreradId: arbetsgivare, redovisningsperiod: period,
|
|
outcome: kontroll.ok ? 'ok' : 'skv_error', responseStatus: kontroll.status,
|
|
skvStatus: kontroll.ok ? kontroll.data.status : null,
|
|
})
|
|
if (!kontroll.ok) {
|
|
return { ok: false, code: 'SKATTEVERKET_SUBMIT_REJECTED', http_status: kontroll.status,
|
|
recoverable: false, error: kontroll.error }
|
|
}
|
|
if (kontroll.data.status === 'PROCESSING') {
|
|
// Still processing after the bounded poll — recoverable; re-approve shortly.
|
|
return { ok: false, code: 'SKATTEVERKET_PROCESSING', http_status: 202, recoverable: true,
|
|
error: 'Skatteverket bearbetar fortfarande AGI-underlaget. Försök igen om en stund.' }
|
|
}
|
|
if (kontroll.data.status === 'DONE_REJECTED' || kontroll.data.status === 'DONE_FAILED') {
|
|
// Scope by salary_run_id, not just period: a correction run sharing the
|
|
// period must not have its (still-valid) declaration flipped to rejected.
|
|
await supabase.from('agi_declarations').update({ status: 'rejected' })
|
|
.eq('company_id', companyId).eq('salary_run_id', salaryRunId)
|
|
.eq('period_year', periodYear).eq('period_month', periodMonth)
|
|
.in('status', ['generated', 'pending_signature', 'exported'])
|
|
return { ok: false, code: 'AGI_KONTROLL_REJECTED', http_status: 422, recoverable: false,
|
|
error: 'Skatteverket avvisade AGI-underlaget vid kontroll. Åtgärda felen och generera om AGI:n.' }
|
|
}
|
|
|
|
// 3. skapaGranskningsunderlag (lasPeriod=true) → Mina Sidor signing link.
|
|
const gransk = await agiSkapaGranskningsunderlag(supabase, userId, arbetsgivare, period, { lasPeriod: true })
|
|
await writeSkatteverketAudit(ctx, {
|
|
endpoint: 'agi/granskningsunderlag', agRegistreradId: arbetsgivare, redovisningsperiod: period,
|
|
outcome: gransk.ok ? 'ok' : 'skv_error', responseStatus: gransk.status,
|
|
skvStatus: gransk.ok ? gransk.data.tillstand : null,
|
|
})
|
|
if (!gransk.ok) {
|
|
return { ok: false, code: 'SKATTEVERKET_SUBMIT_REJECTED', http_status: gransk.status,
|
|
recoverable: false, error: gransk.error }
|
|
}
|
|
const tillstand = gransk.data.tillstand
|
|
const canSign = tillstand === 'LOCKED_FOR_SIGNING' || tillstand === 'UNLOCKED'
|
|
|
|
await ctx.settings.set(`agi_submission_${period}`, JSON.stringify({
|
|
status: canSign ? 'awaiting_signing' : 'underlag_rejected', arbetsgivare, period,
|
|
signeringslank: gransk.data.link, tillstand, meddelande: gransk.data.meddelande,
|
|
updatedAt: new Date().toISOString(),
|
|
}))
|
|
|
|
if (tillstand === 'INCORRECT_DATA') {
|
|
return { ok: false, code: 'AGI_GRANSKNING_INCORRECT', http_status: 422, recoverable: false,
|
|
error: gransk.data.meddelande || 'Skatteverket avvisade granskningsunderlaget. Åtgärda felen och generera om AGI:n.' }
|
|
}
|
|
if (!canSign) {
|
|
// RECEIVING / CALCULATING etc. — still processing, recoverable.
|
|
return { ok: false, code: 'SKATTEVERKET_PROCESSING', http_status: 202, recoverable: true,
|
|
error: gransk.data.meddelande || 'Skatteverket bearbetar fortfarande underlaget. Försök igen om en stund.' }
|
|
}
|
|
|
|
// Flip the declaration to pending_signature (monotonic guard). Scoped by
|
|
// salary_run_id so a correction run in the same period isn't co-flipped —
|
|
// more precise than the period-only route handler, which has no run id.
|
|
await supabase.from('agi_declarations').update({ status: 'pending_signature' })
|
|
.eq('company_id', companyId).eq('salary_run_id', salaryRunId)
|
|
.eq('period_year', periodYear).eq('period_month', periodMonth)
|
|
.in('status', ['generated', 'rejected'])
|
|
|
|
return {
|
|
ok: true,
|
|
signing_url: gransk.data.link,
|
|
arbetsgivare,
|
|
period,
|
|
inlamning_id: inlamningId,
|
|
tillstand,
|
|
}
|
|
} catch (err) {
|
|
return mapServiceError(ctx, 'agi/submit', err)
|
|
}
|
|
}
|