Files
accounted/app/api/extensions/skatteverket/agi/kvittenser/cron/route.ts
T
Mattsson e11f70b347 Bug/gh issues fiz (#1103)
* refactor: optimize page loading and data fetching

* fix: resolve recurring production runtime errors

* feat: add MCP company and customer updates

* fix: handle year-end tax adjustments

* feat: harden annual report compliance

* fix: expand invoice logo and font support

* fix: sanitize API route error responses

* fix: sanitize user-facing error messages

* feat: persist onboarding and tax assessment notices

* fix: reduce cloud backup audit churn

* feat: refine invoice editor layout

* fix: show saved tax adjustments in INK2

* fix: complete annual report API mappings

* docs: record operational safeguards and decisions

* fix: harden annual report review findings

* fix: adjust column span for description based on VAT registration

* New css class name
2026-07-21 23:00:15 +02:00

239 lines
10 KiB
TypeScript

import { createClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { createLogger } from '@/lib/logger'
import { verifyCronSecret } from '@/lib/auth/cron'
import { SkatteverketAuthError } from '@/extensions/general/skatteverket/lib/api-client'
import { markNeedsReconsent, RECONSENT_ERROR_CODES } from '@/extensions/general/skatteverket/lib/token-store'
import { currentSkvEnvironment } from '@/extensions/general/skatteverket/lib/resolve-auth'
import { markGrantRevoked } from '@/extensions/general/skatteverket/lib/connection-store'
import { reconcileAgiDeclaration } from '@/extensions/general/skatteverket/lib/agi-kvittens-reconcile'
import { formatRedovisningsperiod } from '@/lib/skatteverket/format'
import { hasCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { getErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
export const maxDuration = 60
// Failure logs route through the structured logger so third-party error
// strings pass its redaction. The APIGW warn / budget + summary logs /
// capability skip stay on console.*: their content is fixed internal strings.
const log = createLogger('agi-kvittenser-cron')
// Cron responses must never be cached: they report a point-in-time run.
const NO_STORE_HEADERS = { 'Cache-Control': 'no-store' } as const
/**
* GET /api/extensions/skatteverket/agi/kvittenser/cron
*
* Daily kvittens reconciliation. The user-side flow signs the AGI in
* Skatteverket's Mina Sidor; the resulting kvittens (uuidKvittens +
* signeradTid) is the canonical filing receipt. Without this cron,
* `salary_runs.agi_submitted_at` only gets stamped when the user returns
* to the panel and clicks "Hämta kvittens" or stays on the page long
* enough for the in-browser timers to fire, which is unreliable, and
* leaves the audit trail out of step with reality (BFNAR 2013:2 kap 8 +
* BFL 5 kap 5§ require the behandlingshistorik to faithfully record
* filing events).
*
* Strategy: walk every `agi_declarations` row in `pending_signature`
* status, look up its arbetsgivare/period, fetch /kvittenser via the
* extension's per-user token, and on a hit promote the row to
* `submitted` + stamp salary_runs.agi_submitted_at.
*
* Per-row errors are logged and skipped: one expired token shouldn't
* block other companies' reconciliation.
*
* Time budget: 50s (Vercel default 60s function timeout with 10s margin).
*/
export async function GET(request: Request) {
const authError = verifyCronSecret(request)
if (authError) return authError
if (process.env.SKATTEVERKET_ENABLED !== 'true') {
return NextResponse.json(
{ message: 'Skatteverket extension disabled', processed: 0 },
{ headers: NO_STORE_HEADERS },
)
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !supabaseServiceKey) {
return NextResponse.json(
{ error: 'Missing Supabase configuration' },
{ status: 500, headers: NO_STORE_HEADERS },
)
}
const supabase = createClient(supabaseUrl, supabaseServiceKey)
const { data: pending, error: pendingError } = await supabase
.from('agi_declarations')
.select('id, company_id, salary_run_id, period_year, period_month')
.eq('status', 'pending_signature')
.order('created_at', { ascending: true })
.limit(100)
if (pendingError) {
log.error('Failed to fetch pending declarations', {
message: pendingError.message,
code: pendingError.code,
})
return NextResponse.json(
{ error: 'Failed to fetch pending declarations' },
{ status: 500, headers: NO_STORE_HEADERS },
)
}
if (!pending || pending.length === 0) {
return NextResponse.json(
{ message: 'No pending signatures', processed: 0 },
{ headers: NO_STORE_HEADERS },
)
}
const startTime = Date.now()
const TIME_BUDGET_MS = 50_000
// No companyId here: results echo back in the HTTP response body, so
// tenant identifiers stay in internal log context only (declarationId
// is enough to find the row).
type Result = {
declarationId: string
period: string
status: 'signed' | 'still_pending' | 'already_claimed' | 'no_token' | 'no_company_settings' | 'expired_token' | 'grant_revoked' | 'apigw_config' | 'error'
error?: string
}
const results: Result[] = []
// The APIGW subscription gap is one run-level configuration problem, not a
// per-declaration one: warn once per run instead of spamming an identical
// warning for every affected declaration.
let apigwAccessDeniedWarned = false
for (const decl of pending) {
if (Date.now() - startTime > TIME_BUDGET_MS) {
console.log(`[agi-kvittenser-cron] Time budget reached after ${results.length} declarations`)
break
}
const companyId = decl.company_id as string
const declarationId = decl.id as string
const period = formatRedovisningsperiod('monthly', decl.period_year as number, decl.period_month as number)
if (!(await hasCapability(supabase, companyId, CAPABILITY.skatteverket))) {
console.info('[agi-kvittenser-cron] skip: capability not entitled', { companyId })
continue
}
try {
// The shared reconciler resolves auth (system grant → user token),
// fetches the kvittens, and on a hit promotes the declaration +
// stamps salary_runs / deadline / notification. Auth errors propagate
// to the catch below, which owns the cron-specific side effects.
const outcome = await reconcileAgiDeclaration(
supabase,
{
id: declarationId,
company_id: companyId,
salary_run_id: (decl.salary_run_id as string | null) ?? null,
period_year: decl.period_year as number,
period_month: decl.period_month as number,
},
{ reconciledBy: 'cron' },
)
const result: Result = { declarationId, period, status: outcome.status }
if ('error' in outcome) result.error = outcome.error
results.push(result)
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
if (err instanceof SkatteverketAuthError && err.code === 'OMBUD_GRANT_MISSING') {
// System-mode read rejected: the company withdrew the behorighet.
// Downgrade the connection row so the next run falls back to the
// user token (if any). Never touches skatteverket_tokens.
await markGrantRevoked(companyId, currentSkvEnvironment(), 'lasombud', err.code)
results.push({ declarationId, period, status: 'grant_revoked', error: err.code })
continue
}
if (
err instanceof SkatteverketAuthError &&
(RECONSENT_ERROR_CODES as readonly string[]).includes(err.code)
) {
// Persist the health flag so both crons stop retrying this
// connection and the UI can prompt for re-consent proactively.
const { data: tokenRow } = await supabase
.from('skatteverket_tokens')
.select('user_id')
.eq('company_id', companyId)
.maybeSingle()
if (tokenRow?.user_id) {
await markNeedsReconsent(supabase, tokenRow.user_id as string, err.code)
}
results.push({ declarationId, period, status: 'expired_token', error: err.code })
continue
}
if (err instanceof SkatteverketAuthError && err.code === 'TOKEN_REVOKED') {
// skvRequest already deleted the token row.
results.push({ declarationId, period, status: 'expired_token', error: err.code })
continue
}
if (err instanceof SkatteverketAuthError && err.code === 'ACCESS_DENIED') {
// Skatteverkets API gateway rejected our client credentials before
// the user's bearer was ever evaluated: the APIGW client
// (SKATTEVERKET_APIGW_CLIENT_ID) lacks an Utvecklarportalen
// subscription for the AGI hantera API. Retrying every run cannot
// heal this and the user reconnecting via BankID does not help, so
// log at warn level instead of error to keep the 2h cron from
// producing error-noise for a known configuration gap. The distinct
// status keeps the gap visible in the run summary until fixed. The
// warn is emitted once per run (context is the first affected
// declaration); every affected declaration still lands in results.
if (!apigwAccessDeniedWarned) {
apigwAccessDeniedWarned = true
console.warn(
'[agi-kvittenser-cron] APIGW client lacks Utvecklarportalen subscription for the AGI hantera API; check SKATTEVERKET_APIGW_CLIENT_ID subscriptions. Skipping affected declarations until the subscription is added.',
{ declarationId, companyId, period, message },
)
}
results.push({ declarationId, period, status: 'apigw_config', error: err.code })
continue
}
log.error('Reconciliation failed', { declarationId, companyId, period, message })
results.push({ declarationId, period, status: 'error', error: getErrorMessage(err) })
}
}
const signed = results.filter(r => r.status === 'signed').length
const stillPending = results.filter(r => r.status === 'still_pending').length
const alreadyClaimed = results.filter(r => r.status === 'already_claimed').length
const expired = results.filter(r => r.status === 'expired_token').length
const grantRevoked = results.filter(r => r.status === 'grant_revoked').length
const apigwConfig = results.filter(r => r.status === 'apigw_config').length
const errors = results.filter(r => r.status === 'error').length
console.log(
`[agi-kvittenser-cron] Processed ${results.length}: ${signed} signed, ${stillPending} still pending, ${alreadyClaimed} already claimed, ${expired} expired, ${grantRevoked} grants revoked, ${apigwConfig} apigw config gaps, ${errors} errors`,
)
return NextResponse.json(
{
processed: results.length,
signed,
stillPending,
alreadyClaimed,
expired,
grantRevoked,
apigwConfig,
errors,
results,
},
{ headers: NO_STORE_HEADERS },
)
}