Bug/skv auth (#1010)

* fix(settings): open Skatteverket connect in a popup and fix the skahmst scope check

The full-page OAuth round-trip left Skatteverket's pages and the consumed
callback URL in browser history directly beneath /settings/tax, so closing
settings walked Back into a dead OAuth chain and re-prompted BankID auth.
The connect buttons now use the AGIPanel popup + postMessage pattern (the
callback already supports window.opener); the page never navigates and the
panel refetches status on success. Full-page navigation remains only as the
popup-blocked fallback.

Also fixes the reconnect-button condition: it checked for a scope literally
named 'skattekonto', but SKV grants 'skahmst', which kept "Anslut igen"
permanently visible on healthy connections.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(skatteverket): extract per-declaration AGI kvittens reconciliation

Behavior-identical extraction of the kvittens cron's core (fetch kvittens,
promote declaration to submitted, stamp salary_runs, clear cached submission,
complete deadline, notify) into lib/agi-kvittens-reconcile.ts so the upcoming
post-connect refresh can reuse it. Auth-error mapping (needs_reconsent, grant
revocation, APIGW config gaps) and run-level logging stay in the cron, which
is why SkatteverketAuthError propagates out of the helper on purpose.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(skatteverket): auto-settle production-format AGI skattekonto draws

The settlement matcher only understood the SKV test environment's single
combined row ("Arbetsgivardeklaration YYYYMM"). Production books the draw as
two rows with Swedish month names: "Avdragen skatt maj 2026" (= total_tax)
and "Arbetsgivaravgift maj 2026" (= total_avgifter), so auto-settlement has
never fired against production data and the salary page kept showing paid
periods as unpaid.

parseAgiPeriod now also reads the month-name form (which additionally lets
match suggestions resolve the period on prod rows, including beslut rows).
Settlement classifies draws with start-anchored regexes so correction rows
like "Beslut 260703 arbetsgivaravgift mars 2026" can never qualify, and
settles a period on either the combined row matching the whole declared
amount or exactly one tax row + exactly one avgift row matching their
respective totals to the ore, dated to the later of the pair. Anything
non-exact (partial draws, duplicates) still falls back to the manual
mark-paid button.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skatteverket): refresh skattekonto + kvittenser right after OAuth consent

Nothing fetched Skatteverket data after a (re)connect: tokens were stored and
the user redirected, with the next fetch left to the nightly cron. SKV's
per-flow tokens (and refresh tokens) live ~65 minutes, so the crons usually
find them dead; right after consent is the one reliable window for a
personal-token fetch, which is why reconnecting never made stale numbers
recover.

The callback now awaits runPostConnectRefresh after storing tokens: a
skattekonto sync (upsert + auto-settlement + balance snapshot) plus a
kvittens re-check for this company's pending_signature AGI declarations.
Awaited on purpose so popup-close means the data is already fresh and UI
refetch listeners never race a background job; every step is best-effort and
a refresh failure can never fail the connect that just succeeded.

The callback's non-popup fallback also switches to window.location.replace
so the consumed callback URL (one-shot code + state) drops out of history
instead of re-running into a guaranteed CSRF error on Back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(salary): reload the salary dashboard after a Skatteverket reconnect

Listens for the BankID popup's skatteverket-oauth-success message and re-runs
load(). Because the OAuth callback awaits the skattekonto sync and AGI
auto-settlement before responding, the refetch already sees settled
tax-payment state: the "Skatt att betala" card flips to paid the moment the
popup closes, without leaving the page.

Also logs the three decisions behind this series in DECISIONS.md (awaited
post-connect refresh over after(), exact-pair settlement over per-period
summing, popup flow over in-place history repair).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(skatteverket): verify OAuth popup source identity before trusting postMessage

The three 'skatteverket-oauth-success' listeners accepted any same-origin
message, letting any same-origin script trigger a data reload or a fake
success toast (OWASP ASVS V3.2, SOC 2 CC6.1). The two components that open
the popup now keep its handle in a ref and require event.source to be that
exact window; a window reference cannot be forged by same-origin scripts,
which is strictly stronger than a nonce threaded through the OAuth flow.

The salary dashboard never opens the popup, so its raw message listener is
replaced by a 'skatteverket-connection-updated' CustomEvent dispatched only
after a component has source-verified the popup (and after disconnect, so
connection-state consumers stay in sync).

Also extends the connect consent copy (sv + en) to disclose that connecting
immediately fetches skattekonto data and checks pending AGI receipts
(GDPR Art.5(1)(b) transparency).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(skatteverket): CSP nonce and no-store on OAuth callback, deadline on post-connect refresh

Both callback HTML responses now carry a per-response nonce CSP
(default-src 'none'; script-src 'nonce-...'; base-uri 'none';
form-action 'none') so injected markup could never execute, plus
Cache-Control: no-store because the callback URL carries a one-shot
authorization code. The jsLiteral/appUrl injection invariants are
documented at the definition site.

The awaited post-connect refresh is bounded by a 30-second Promise.race
deadline so a hung SKV call cannot hold the OAuth callback open; on
timeout the refresh continues best-effort and the user still gets the
success response. Refresh failures and timeouts now log through the
structured logger with companyId + userId so they are attributable in
log aggregation (ASVS V16, ISO 27001 A.8.15, SOC 2 CC8.1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(skatteverket): tenant guard and redacting logs in kvittens reconcile, slim cron response

The agi_declarations update now also filters on company_id (ASVS V8.2.1),
matching the salary_runs pattern. The reconciler and the cron's error paths
log through the structured logger so third-party error strings pass
personnummer redaction; uuidKvittens is dropped from log context
(GDPR Art.5(1)(f) minimization, declarationId suffices).

response_data gains submittedAtEstimated so the signeradTid fallback can
never be mistaken for the legal filing time, and the submitted_by comment
now states explicitly that it records the technical submitter while
response_data.signeradAv is the authoritative legal signatory
(BFL 5 kap 6 par, BFNAR 2013:2 kap 8).

The cron HTTP response omits companyId per result row and sends
Cache-Control: no-store; the extension_data delete documents why the
period-scoped cache key needs no declaration-id guard. Cron tests observe
the logger via a mock without weakening any assertion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(skatteverket): diagnosable settlement refusals and signeradAv ROPA documentation

Settlement refusals (candidates present but amounts mismatch) now emit a
structured info log with declared-vs-drawn ore amounts per kind, so a
rounding divergence between stored declaration totals and SKV's actual
draw is diagnosable instead of silently falling back to the manual button.
No transaction texts are logged (they can carry personal data).

parseAgiPeriod documents the beslut-row audit: correction rows parse to
their period on purpose for match-suggestion boosting; settlement never
uses parseAgiPeriod (anchored classifiers + parseNumericAgiPeriod only)
and the only callers require an exact amount+side 1630 match first.

.compliance/ropa.yaml documents signeradAv (signer personnummer in the
SKV kvittens stored in agi_declarations.response_data): lawful basis
Art.6(1)(c) via BFL 5 kap 6 par / BFNAR 2013:2 kap 8, 7-year retention
per BFL 7 kap 2 par, access via company-membership RLS. DECISIONS.md
records the accepted-with-documentation calls from the compliance review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(skatteverket): add 'already_claimed' status to reconcile outcomes and enhance logging for pending lookups

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-13 01:40:03 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent f04dc4c4e0
commit aab7e47c35
19 changed files with 1433 additions and 239 deletions
@@ -13,6 +13,25 @@ vi.mock('@/lib/auth/cron', () => ({
verifyCronSecret: vi.fn().mockReturnValue(null),
}))
// The route and the reconcile helper log through '@/lib/logger'. The real
// logger suppresses info/warn under NODE_ENV=test, so warn/error output is
// observed via these recorders instead of console spies. Console spies stay
// for the route's remaining console.* lines (APIGW warn, summary, skip).
const { warnRecorder, errorRecorder } = vi.hoisted(() => ({
warnRecorder: vi.fn(),
errorRecorder: vi.fn(),
}))
vi.mock('@/lib/logger', () => {
const logger = {
info: vi.fn(),
warn: warnRecorder,
error: errorRecorder,
child: (): unknown => logger,
}
return { createLogger: () => logger }
})
vi.mock('@/extensions/general/skatteverket/lib/agi-client', () => ({
agiGetKvittenser: vi.fn(),
}))
@@ -191,9 +210,12 @@ describe('AGI kvittenser cron', () => {
expect(body.errors).toBe(0)
expect(body.grantRevoked).toBe(0)
expect(body.results[0].status).toBe('signed')
// Tenant identifiers stay in internal log context only.
expect(body.results[0]).not.toHaveProperty('companyId')
expect(mockCompleteTaxDeadline).toHaveBeenCalledTimes(1)
expect(mockSendKvittensNotification).toHaveBeenCalledTimes(1)
expect(errorSpy).not.toHaveBeenCalled()
expect(errorRecorder).not.toHaveBeenCalled()
})
it('still reports signed and sends the notification when completeTaxDeadline throws', async () => {
@@ -234,9 +256,10 @@ describe('AGI kvittenser cron', () => {
referenceId: 'decl-1',
})
// The failure is a warning, not an error.
// The failure is a warning (via the structured logger), not an error.
expect(errorSpy).not.toHaveBeenCalled()
const warnMessages = warnSpy.mock.calls.map(c => String(c[0]))
expect(errorRecorder).not.toHaveBeenCalled()
const warnMessages = warnRecorder.mock.calls.map(c => String(c[0]))
expect(warnMessages.some(m => m.includes('completeTaxDeadline failed'))).toBe(true)
})
@@ -264,7 +287,8 @@ describe('AGI kvittenser cron', () => {
expect(body.errors).toBe(0)
expect(body.results[0].status).toBe('signed')
expect(errorSpy).not.toHaveBeenCalled()
const warnMessages = warnSpy.mock.calls.map(c => String(c[0]))
expect(errorRecorder).not.toHaveBeenCalled()
const warnMessages = warnRecorder.mock.calls.map(c => String(c[0]))
expect(warnMessages.some(m => m.includes('sendKvittensNotification failed'))).toBe(true)
})
@@ -302,10 +326,11 @@ describe('AGI kvittenser cron', () => {
expect(body.errors).toBe(0)
expect(body.results[0]).toMatchObject({
declarationId: 'decl-1',
companyId: 'comp-1',
status: 'apigw_config',
error: 'ACCESS_DENIED',
})
// companyId is internal log context, never response payload.
expect(body.results[0]).not.toHaveProperty('companyId')
// Warn carries the actionable config hint plus the context to act on it.
expect(warnSpy).toHaveBeenCalledTimes(1)
@@ -320,6 +345,7 @@ describe('AGI kvittenser cron', () => {
// The whole point: no error-level log for a config gap retries cannot heal.
expect(errorSpy).not.toHaveBeenCalled()
expect(errorRecorder).not.toHaveBeenCalled()
expect(mockMarkNeedsReconsent).not.toHaveBeenCalled()
// The config gap stays visible in the run summary.
@@ -360,11 +386,13 @@ describe('AGI kvittenser cron', () => {
'decl-3',
])
expect(body.results.every((r: { status: string }) => r.status === 'apigw_config')).toBe(true)
expect(body.results.every((r: Record<string, unknown>) => !('companyId' in r))).toBe(true)
// But the identical config-gap warning is logged exactly once per run.
expect(warnSpy).toHaveBeenCalledTimes(1)
expect(String(warnSpy.mock.calls[0][0])).toContain('Utvecklarportalen')
expect(errorSpy).not.toHaveBeenCalled()
expect(errorRecorder).not.toHaveBeenCalled()
const summaryLine = logSpy.mock.calls.map(c => String(c[0])).find(m => m.includes('Processed'))
expect(summaryLine).toContain('3 apigw config gaps')
@@ -384,7 +412,9 @@ describe('AGI kvittenser cron', () => {
expect(body.results[0]).toMatchObject({ status: 'expired_token', error: 'SESSION_EXPIRED' })
expect(mockMarkNeedsReconsent).toHaveBeenCalledWith(expect.anything(), 'user-1', 'SESSION_EXPIRED')
expect(errorSpy).not.toHaveBeenCalled()
expect(errorRecorder).not.toHaveBeenCalled()
expect(warnSpy).not.toHaveBeenCalled()
expect(warnRecorder).not.toHaveBeenCalled()
})
it('still records expired_token for TOKEN_REVOKED without reconsent flagging', async () => {
@@ -400,6 +430,7 @@ describe('AGI kvittenser cron', () => {
expect(body.results[0]).toMatchObject({ status: 'expired_token', error: 'TOKEN_REVOKED' })
expect(mockMarkNeedsReconsent).not.toHaveBeenCalled()
expect(errorSpy).not.toHaveBeenCalled()
expect(errorRecorder).not.toHaveBeenCalled()
})
it('still logs at error level for other SkatteverketAuthError codes', async () => {
@@ -414,9 +445,10 @@ describe('AGI kvittenser cron', () => {
expect(body.errors).toBe(1)
expect(body.apigwConfig).toBe(0)
expect(body.results[0]).toMatchObject({ status: 'error', error: 'Du har inte behörighet.' })
expect(errorSpy).toHaveBeenCalledTimes(1)
expect(String(errorSpy.mock.calls[0][0])).toContain('Reconciliation failed')
expect(errorRecorder).toHaveBeenCalledTimes(1)
expect(String(errorRecorder.mock.calls[0][0])).toContain('Reconciliation failed')
expect(warnSpy).not.toHaveBeenCalled()
expect(warnRecorder).not.toHaveBeenCalled()
})
it('still logs at error level for generic errors', async () => {
@@ -428,6 +460,7 @@ describe('AGI kvittenser cron', () => {
expect(body.errors).toBe(1)
expect(body.results[0]).toMatchObject({ status: 'error', error: 'fetch failed' })
expect(errorSpy).toHaveBeenCalledTimes(1)
expect(errorRecorder).toHaveBeenCalledTimes(1)
expect(String(errorRecorder.mock.calls[0][0])).toContain('Reconciliation failed')
})
})
@@ -1,15 +1,14 @@
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 { agiGetKvittenser } from '@/extensions/general/skatteverket/lib/agi-client'
import { SkatteverketAuthError } from '@/extensions/general/skatteverket/lib/api-client'
import { markNeedsReconsent, RECONSENT_ERROR_CODES } from '@/extensions/general/skatteverket/lib/token-store'
import { sendKvittensNotification } from '@/extensions/general/skatteverket/lib/kvittens-notification'
import { resolveReadAuth, currentSkvEnvironment } from '@/extensions/general/skatteverket/lib/resolve-auth'
import { currentSkvEnvironment } from '@/extensions/general/skatteverket/lib/resolve-auth'
import { markGrantRevoked } from '@/extensions/general/skatteverket/lib/connection-store'
import { formatRedovisare, formatRedovisningsperiod } from '@/lib/skatteverket/format'
import { completeTaxDeadline } from '@/lib/deadlines/complete-tax-deadline'
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'
@@ -17,6 +16,14 @@ 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
*
@@ -45,13 +52,19 @@ export async function GET(request: Request) {
if (authError) return authError
if (process.env.SKATTEVERKET_ENABLED !== 'true') {
return NextResponse.json({ message: 'Skatteverket extension disabled', processed: 0 })
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 })
return NextResponse.json(
{ error: 'Missing Supabase configuration' },
{ status: 500, headers: NO_STORE_HEADERS },
)
}
const supabase = createClient(supabaseUrl, supabaseServiceKey)
@@ -64,25 +77,33 @@ export async function GET(request: Request) {
.limit(100)
if (pendingError) {
console.error('[agi-kvittenser-cron] Failed to fetch pending declarations', {
log.error('Failed to fetch pending declarations', {
message: pendingError.message,
code: pendingError.code,
})
return NextResponse.json({ error: 'Failed to fetch pending declarations' }, { status: 500 })
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 })
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
companyId: string
period: string
status: 'signed' | 'still_pending' | 'no_token' | 'no_company_settings' | 'expired_token' | 'grant_revoked' | 'apigw_config' | 'error'
status: 'signed' | 'still_pending' | 'already_claimed' | 'no_token' | 'no_company_settings' | 'expired_token' | 'grant_revoked' | 'apigw_config' | 'error'
error?: string
}
const results: Result[] = []
@@ -107,158 +128,25 @@ export async function GET(request: Request) {
}
try {
// Auth resolution prefers system credentials (verified lasombud grant)
// and falls back to the company's user token: kvittens polling is the
// canonical case for the hybrid model, since the user signed at SKV
// and their 65-minute session is usually long dead by the time the
// kvittens exists.
const resolved = await resolveReadAuth(supabase, companyId, { requires: 'lasombud' })
if (!resolved.ok) {
if (resolved.reason === 'needs_reconsent') {
// A connection flagged needs_reconsent cannot heal on its own
// (SKV's per-flow refresh tokens live 65 minutes): skip quietly
// instead of failing the same declaration every run.
results.push({ declarationId, companyId, period, status: 'expired_token', error: 'needs_reconsent' })
} else {
results.push({ declarationId, companyId, period, status: 'no_token' })
}
continue
}
const { data: settings } = await supabase
.from('company_settings')
.select('org_number, entity_type')
.eq('company_id', companyId)
.single()
if (!settings?.org_number) {
results.push({ declarationId, companyId, period, status: 'no_company_settings' })
continue
}
const arbetsgivare = formatRedovisare(
settings.org_number as string,
settings.entity_type as 'enskild_firma' | 'aktiebolag',
// 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 kvittRes = await agiGetKvittenser(resolved.auth, arbetsgivare, period)
if (!kvittRes.ok) {
results.push({
declarationId, companyId, period,
status: 'error',
error: kvittRes.error,
})
continue
}
const kvittens = kvittRes.data.kvittenser?.[0]
if (!kvittens?.uuidKvittens) {
results.push({ declarationId, companyId, period, status: 'still_pending' })
continue
}
// 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 behandlingshistorik (BFNAR 2013:2
// kap 8 / BFL 5 kap 6§). The fallback only applies on this code
// path because we're inside the kvittens-found branch above.
const submittedAt = kvittens.signeradTid || new Date().toISOString()
if (!kvittens.signeradTid) {
console.warn('[agi-kvittenser-cron] kvittens missing signeradTid; using reconciliation time', {
declarationId, companyId, period, uuidKvittens: kvittens.uuidKvittens,
})
}
// submitted_by is the token-owning auth.users row: the human who
// connected via BankID. The legally load-bearing signer identity
// is kvittens.signeradAv (a personnummer), which the token user_id
// does NOT necessarily match (e.g. if the connected user is a
// bookkeeper but the deklarationsombud signed). We preserve the
// full kvittens in response_data so the audit trail (BFL 5 kap 6§,
// BFNAR 2013:2 kap 8) records the actual BankID signer regardless
// of who triggered the reconciliation.
await supabase
.from('agi_declarations')
.update({
status: 'submitted',
kvittensnummer: kvittens.uuidKvittens,
submitted_at: submittedAt,
submitted_by: resolved.tokenUserId,
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,
reconciledBy: 'cron',
},
})
.eq('id', declarationId)
if (decl.salary_run_id) {
await supabase
.from('salary_runs')
.update({ agi_submitted_at: submittedAt })
.eq('id', decl.salary_run_id)
.eq('company_id', companyId)
}
// Clear the locally-cached submission state so the panel doesn't
// pop a stale "awaiting signature" view if the user revisits.
await supabase
.from('extension_data')
.delete()
.eq('company_id', companyId)
.eq('extension_id', 'skatteverket')
.eq('key', `agi_submission_${period}`)
// The declaration is already flipped to submitted above, and the next
// run only revisits pending_signature rows: from here on everything is
// best-effort. Each step gets its own try/catch so a failure is logged
// as a warning without masking the successful filing or skipping the
// remaining confirmation steps.
// The kvittens is the canonical filing receipt: confirm the period's
// arbetsgivardeklaration deadline (terminal state).
try {
await completeTaxDeadline(
supabase,
companyId,
['arbetsgivardeklaration'],
`${decl.period_year}-${String(decl.period_month).padStart(2, '0')}`,
'confirmed'
)
} catch (deadlineErr) {
console.warn('[agi-kvittenser-cron] completeTaxDeadline failed after successful filing', {
declarationId, companyId, period,
message: deadlineErr instanceof Error ? deadlineErr.message : 'Unknown error',
})
}
// Tell the user: signing happened at Skatteverket, often long after
// they closed our tab, so this is the only confirmation they get.
if (resolved.tokenUserId) {
try {
await sendKvittensNotification(supabase, {
companyId,
userId: resolved.tokenUserId,
kind: 'agi',
period,
kvittensnummer: kvittens.uuidKvittens,
referenceId: declarationId,
})
} catch (notifyErr) {
console.warn('[agi-kvittenser-cron] sendKvittensNotification failed after successful filing', {
declarationId, companyId, period,
message: notifyErr instanceof Error ? notifyErr.message : 'Unknown error',
})
}
}
results.push({ declarationId, companyId, period, status: 'signed' })
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'
@@ -267,7 +155,7 @@ export async function GET(request: Request) {
// 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, companyId, period, status: 'grant_revoked', error: err.code })
results.push({ declarationId, period, status: 'grant_revoked', error: err.code })
continue
}
@@ -285,12 +173,12 @@ export async function GET(request: Request) {
if (tokenRow?.user_id) {
await markNeedsReconsent(supabase, tokenRow.user_id as string, err.code)
}
results.push({ declarationId, companyId, period, status: 'expired_token', error: 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, companyId, period, status: 'expired_token', error: err.code })
results.push({ declarationId, period, status: 'expired_token', error: err.code })
continue
}
if (err instanceof SkatteverketAuthError && err.code === 'ACCESS_DENIED') {
@@ -311,34 +199,39 @@ export async function GET(request: Request) {
{ declarationId, companyId, period, message },
)
}
results.push({ declarationId, companyId, period, status: 'apigw_config', error: err.code })
results.push({ declarationId, period, status: 'apigw_config', error: err.code })
continue
}
console.error('[agi-kvittenser-cron] Reconciliation failed', { declarationId, companyId, period, message })
results.push({ declarationId, companyId, period, status: 'error', error: message })
log.error('Reconciliation failed', { declarationId, companyId, period, message })
results.push({ declarationId, period, status: 'error', error: message })
}
}
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, ${expired} expired, ${grantRevoked} grants revoked, ${apigwConfig} apigw config gaps, ${errors} errors`,
`[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,
expired,
grantRevoked,
apigwConfig,
errors,
results,
})
return NextResponse.json(
{
processed: results.length,
signed,
stillPending,
alreadyClaimed,
expired,
grantRevoked,
apigwConfig,
errors,
results,
},
{ headers: NO_STORE_HEADERS },
)
}