fix(salary): show the AGI kvittensnummer from agi_declarations regardless of who fetched it (#1692)

* fix(salary): show the AGI kvittensnummer from agi_declarations regardless of who fetched it

When the kvittens cron (or the post-connect refresh) picks up a signed AGI it
deletes the period-scoped agi_submission_{period} cache on purpose, and the
salary run then rendered "Skickad till Skatteverket <date>" with no
kvittensnummer, signatory or signing time even though all three were stored
on agi_declarations. Since the cron runs every 15 minutes while the panel
polls only three times after the signing link is created, that was the
normal outcome for anyone who signs at an unhurried pace (#1597).

GET /agi/status now serves the receipt from agi_declarations
(kvittensnummer, response_data.signeradAv/signeradTid, submitted_at,
submittedAtEstimated) whenever the cache is absent; the cache still wins
when present because it is the only place the in-flight states live. The
declaration-sourced record deliberately carries no salaryRunId (the period
row is repointed at a correction run on regeneration), so ownership is
resolved from signeradTid/submittedAt against the run's agi_submitted_at
stamp and from updatedAt = submitted_at. AGIPanel labels the timestamp as
approximate when it is our reconciliation-time fallback rather than
Skatteverket's signeradTid. The MCP gnubok_agi_status tool uses the same
read.

Closes #1597

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

* ci: retry stalled Vercel preview build

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-18 17:32:23 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 7d56e0ec01
commit 83932f2e07
11 changed files with 571 additions and 25 deletions
@@ -259,6 +259,47 @@ describe('gnubok_agi_status: run-scoped filing state', () => {
expect(result.local_state).toMatchObject({ status: 'signed', salaryRunId: 'sr-original' })
})
it('cache deleted by the kvittens cron: the receipt is served from agi_declarations (#1597)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueueStatusReads(enqueue, {
id: 'sr-original',
period_year: 2026,
period_month: 6,
agi_generated_at: '2026-06-28T08:00:00Z',
agi_submitted_at: '2026-07-01T09:00:00Z',
}, null)
enqueue({
data: {
salary_run_id: 'sr-original',
status: 'submitted',
kvittensnummer: 'KV-ORIG-1',
submitted_at: '2026-07-01T09:00:00Z',
response_data: {
signeradAv: '191212121212',
signeradTid: '2026-07-01T09:00:00Z',
submittedAtEstimated: false,
},
},
}) // agi_declarations
const result = (await agiStatus.execute(
{ salary_run_id: 'sr-original' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' },
)) as {
filing_state: string
kvittensnummer: string | null
local_state: Record<string, unknown> | null
}
expect(result.filing_state).toBe('signed')
expect(result.kvittensnummer).toBe('KV-ORIG-1')
expect(result.local_state).toMatchObject({
status: 'signed',
signeradAv: '191212121212',
submittedAtEstimated: false,
source: 'declaration',
})
})
it('a run with neither XML nor record reports none', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueueStatusReads(enqueue, {
+7 -5
View File
@@ -216,6 +216,7 @@ import { extractInvoiceFields, ExtractionSchema as InvoiceExtractionSchema, Agen
// the skatteverket extension via the registry (lib/pending-operations/commit.ts).
import { skvRequest, SkatteverketAuthError } from '@/extensions/general/skatteverket/lib/api-client'
import { agiGetKvittenser } from '@/extensions/general/skatteverket/lib/agi-client'
import { readAgiSubmissionStatus } from '@/extensions/general/skatteverket/lib/agi-submission-status'
import { buildMomsuppgift, resolveRedovisare } from '@/extensions/general/skatteverket/lib/declaration-prep'
import { writeSkatteverketAudit } from '@/extensions/general/skatteverket/lib/audit'
import { skvAuthCodeToStructured } from '@/extensions/general/skatteverket/lib/error-map'
@@ -12011,7 +12012,9 @@ export const tools: McpTool[] = [
if (!run) throw new Error('Salary run not found')
const arbetsgivare = await resolveRedovisare(supabase, companyId)
const period = formatRedovisningsperiod('monthly', run.period_year, run.period_month)
// Local cached submission state (extension_data key agi_submission_${period}).
// Local cached submission state (extension_data key agi_submission_${period}),
// falling back to the receipt on agi_declarations once the kvittens
// reconciliation has deleted that cache (same read as GET /agi/status).
const { data: localRow } = await supabase
.from('extension_data')
.select('value')
@@ -12019,10 +12022,9 @@ export const tools: McpTool[] = [
.eq('extension_id', 'skatteverket')
.eq('key', `agi_submission_${period}`)
.maybeSingle()
let periodRecord: AgiSubmissionState | null = null
if (localRow?.value) {
try { periodRecord = JSON.parse(localRow.value as string) as AgiSubmissionState } catch { periodRecord = null }
}
const periodRecord: AgiSubmissionState | null = await readAgiSubmissionStatus(
supabase, companyId, period, localRow?.value ?? null,
)
// Run-scope the period-keyed record (lib/salary/agi-submission-state.ts,
// same resolution AGIPanel and the run page use). salary_runs is unique
// per period only for non-corrected runs (partial index, migration
@@ -0,0 +1,264 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
agiSubmissionFromDeclaration,
parseCachedAgiSubmission,
readAgiSubmissionStatus,
} from '../lib/agi-submission-status'
import { skatteverketExtension } from '../index'
import type { ExtensionContext } from '@/lib/extensions/types'
/**
* GET /agi/status feeds AGIPanel and the run page. The kvittens cron deletes
* the `agi_submission_{period}` cache when it promotes the declaration, so
* the route must serve kvittensnummer / signeradAv / signeradTid from
* agi_declarations in that case (#1597); with the cache present it still
* returns the cache untouched.
*/
const PERIOD = '202606'
const SIGNED_ROW = {
salary_run_id: 'run-1',
status: 'submitted',
kvittensnummer: 'e2f1a4c0-kvittens',
submitted_at: '2026-07-14T08:30:00+00:00',
response_data: {
signeradAv: '191212121212',
signeradTid: '2026-07-14T08:30:00Z',
submittedAtEstimated: false,
uuidKvittens: 'e2f1a4c0-kvittens',
reconciledBy: 'cron',
},
}
/** Supabase stub whose agi_declarations read resolves to `row`, recording the filters. */
function makeSupabase(row: unknown) {
const filters: Record<string, unknown> = {}
const reads: string[] = []
const chain: any = {}
chain.select = vi.fn(() => chain)
chain.eq = vi.fn((col: string, val: unknown) => {
filters[col] = val
return chain
})
chain.maybeSingle = vi.fn(async () => ({ data: row, error: null }))
const supabase = {
from: vi.fn((table: string) => {
reads.push(table)
return chain
}),
} as any
return { supabase, filters, reads }
}
describe('agiSubmissionFromDeclaration', () => {
it('builds a signed record with kvittensnummer, signatory and signing time (estimated=false)', () => {
expect(agiSubmissionFromDeclaration(SIGNED_ROW)).toEqual({
status: 'signed',
kvittensnummer: 'e2f1a4c0-kvittens',
signeradAv: '191212121212',
signeradTid: '2026-07-14T08:30:00Z',
submittedAt: '2026-07-14T08:30:00+00:00',
submittedAtEstimated: false,
updatedAt: '2026-07-14T08:30:00+00:00',
source: 'declaration',
})
})
it('flags the reconciliation-time fallback when signeradTid is absent', () => {
const record = agiSubmissionFromDeclaration({
...SIGNED_ROW,
response_data: {
signeradAv: '191212121212',
signeradTid: null,
submittedAtEstimated: true,
},
})
expect(record).toMatchObject({
status: 'signed',
kvittensnummer: 'e2f1a4c0-kvittens',
signeradAv: '191212121212',
submittedAt: '2026-07-14T08:30:00+00:00',
submittedAtEstimated: true,
})
expect(record?.signeradTid).toBeUndefined()
})
it('treats a missing signeradTid as estimated even when the flag predates the receipt', () => {
// The interactive /agi/kvittenser handler writes response_data without
// submittedAtEstimated; the absence of signeradTid is what makes the
// stamp an estimate.
const record = agiSubmissionFromDeclaration({
...SIGNED_ROW,
response_data: { signeradAv: '191212121212', signeradTid: null },
})
expect(record?.submittedAtEstimated).toBe(true)
})
it('never carries salaryRunId: the period row is repointed at a correction run', () => {
expect(agiSubmissionFromDeclaration(SIGNED_ROW)).not.toHaveProperty('salaryRunId')
})
it('returns null while the declaration has no receipt', () => {
expect(agiSubmissionFromDeclaration(null)).toBeNull()
expect(
agiSubmissionFromDeclaration({ ...SIGNED_ROW, status: 'pending_signature', kvittensnummer: null }),
).toBeNull()
expect(agiSubmissionFromDeclaration({ ...SIGNED_ROW, status: 'generated' })).toBeNull()
})
})
describe('parseCachedAgiSubmission', () => {
it('parses the JSON string the extension stores and tolerates garbage', () => {
expect(parseCachedAgiSubmission(JSON.stringify({ status: 'awaiting_signing' }))).toEqual({
status: 'awaiting_signing',
})
expect(parseCachedAgiSubmission('{not json')).toBeNull()
expect(parseCachedAgiSubmission(null)).toBeNull()
expect(parseCachedAgiSubmission('')).toBeNull()
})
})
describe('readAgiSubmissionStatus', () => {
it('cache deleted: serves the receipt from agi_declarations for the company and period', async () => {
const { supabase, filters, reads } = makeSupabase(SIGNED_ROW)
const record = await readAgiSubmissionStatus(supabase, 'company-1', PERIOD, null)
expect(reads).toEqual(['agi_declarations'])
expect(filters).toEqual({ company_id: 'company-1', period_year: 2026, period_month: 6 })
expect(record).toMatchObject({
status: 'signed',
kvittensnummer: 'e2f1a4c0-kvittens',
signeradAv: '191212121212',
signeradTid: '2026-07-14T08:30:00Z',
submittedAtEstimated: false,
source: 'declaration',
})
})
it('cache present: returns the cached record without touching agi_declarations', async () => {
const { supabase, reads } = makeSupabase(SIGNED_ROW)
const cached = {
status: 'awaiting_signing',
signeringslank: 'https://skatteverket.se/sign/abc',
salaryRunId: 'run-2',
updatedAt: '2026-07-20T09:00:00Z',
}
const record = await readAgiSubmissionStatus(supabase, 'company-1', PERIOD, JSON.stringify(cached))
expect(reads).toEqual([])
expect(record).toEqual({ ...cached, source: 'cache' })
})
it('returns null without a DB read for a malformed period', async () => {
const { supabase, reads } = makeSupabase(SIGNED_ROW)
expect(await readAgiSubmissionStatus(supabase, 'company-1', '2026-06', null)).toBeNull()
expect(reads).toEqual([])
})
it('returns null when the declaration is still awaiting signature', async () => {
const { supabase } = makeSupabase({ ...SIGNED_ROW, status: 'pending_signature', kvittensnummer: null })
expect(await readAgiSubmissionStatus(supabase, 'company-1', PERIOD, null)).toBeNull()
})
})
describe('GET /agi/status', () => {
function findRoute() {
const route = skatteverketExtension.apiRoutes?.find(
(r) => r.method === 'GET' && r.path === '/agi/status',
)
if (!route) throw new Error('/agi/status route not registered')
return route
}
function makeContext(opts: { cached?: unknown; row?: unknown }): ExtensionContext {
const { supabase } = makeSupabase(opts.row ?? null)
return {
userId: 'user-1',
companyId: 'company-1',
extensionId: 'skatteverket',
requestId: 'req_test',
supabase,
emit: vi.fn().mockResolvedValue(undefined),
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), child: vi.fn() },
settings: {
get: vi.fn().mockResolvedValue(opts.cached ?? null),
set: vi.fn().mockResolvedValue(undefined),
clear: vi.fn().mockResolvedValue(undefined),
},
} as any
}
function makeRequest(query: string): Request {
return new Request(`http://localhost/api/extensions/ext/skatteverket/agi/status${query}`)
}
beforeEach(() => {
vi.clearAllMocks()
})
it('returns 500 without an extension context', async () => {
const res = await findRoute().handler(makeRequest(`?period=${PERIOD}`))
expect(res.status).toBe(500)
})
it('returns 400 without a period', async () => {
const res = await findRoute().handler(makeRequest(''), makeContext({}))
expect(res.status).toBe(400)
})
it('(a) cache deleted, declaration signed: carries kvittensnummer, signeradAv, signeradTid, estimated=false', async () => {
const ctx = makeContext({ row: SIGNED_ROW })
const res = await findRoute().handler(makeRequest(`?period=${PERIOD}`), ctx)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data).toMatchObject({
status: 'signed',
kvittensnummer: 'e2f1a4c0-kvittens',
signeradAv: '191212121212',
signeradTid: '2026-07-14T08:30:00Z',
submittedAt: '2026-07-14T08:30:00+00:00',
submittedAtEstimated: false,
source: 'declaration',
})
expect(ctx.settings.get).toHaveBeenCalledWith(`agi_submission_${PERIOD}`)
})
it('(b) cache deleted, signeradTid absent and submittedAtEstimated true: estimated flag set', async () => {
const ctx = makeContext({
row: {
...SIGNED_ROW,
response_data: { signeradAv: '191212121212', signeradTid: null, submittedAtEstimated: true },
},
})
const res = await findRoute().handler(makeRequest(`?period=${PERIOD}`), ctx)
const body = await res.json()
expect(body.data).toMatchObject({
status: 'signed',
kvittensnummer: 'e2f1a4c0-kvittens',
signeradAv: '191212121212',
submittedAt: '2026-07-14T08:30:00+00:00',
submittedAtEstimated: true,
})
expect(body.data.signeradTid).toBeUndefined()
})
it('(c) cache present: the cached in-flight record still wins', async () => {
const cached = {
status: 'awaiting_signing',
signeringslank: 'https://skatteverket.se/sign/abc',
salaryRunId: 'run-2',
}
const ctx = makeContext({ cached: JSON.stringify(cached), row: SIGNED_ROW })
const res = await findRoute().handler(makeRequest(`?period=${PERIOD}`), ctx)
const body = await res.json()
expect(body.data).toEqual({ ...cached, source: 'cache' })
expect(ctx.supabase.from).not.toHaveBeenCalled()
})
it('returns null data when nothing has been filed for the period', async () => {
const ctx = makeContext({ row: null })
const res = await findRoute().handler(makeRequest(`?period=${PERIOD}`), ctx)
const body = await res.json()
expect(body.data).toBeNull()
})
})
+8 -7
View File
@@ -46,6 +46,7 @@ import {
} from './lib/agi-client'
import { syncSkattekonto, SKATTEKONTO_BALANCE_SNAPSHOT_KEY, SKATTEKONTO_LAST_SYNCED_AT_KEY } from './lib/skattekonto-sync'
import { runPostConnectRefresh } from './lib/post-connect-refresh'
import { readAgiSubmissionStatus } from './lib/agi-submission-status'
import {
attachBookingSuggestions,
bokforSkattekontoTransaction,
@@ -2053,7 +2054,11 @@ export const skatteverketExtension: Extension = {
// ── AGI: Local submission tracking (UI helper) ──────────────────
// Returns the locally-cached submission state (inlamningId, signing link,
// kvittensnummer if seen). Pure read; never calls Skatteverket.
// kvittensnummer if seen). When the kvittens reconciliation (cron or
// post-connect refresh) has already promoted the declaration it deletes
// that cache, so the receipt is served from agi_declarations instead:
// kvittensnummer, signeradAv and signeradTid must be visible regardless
// of which path fetched them (#1597). Pure read; never calls Skatteverket.
{
method: 'GET',
path: '/agi/status',
@@ -2064,12 +2069,8 @@ export const skatteverketExtension: Extension = {
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 })
}
const data = await readAgiSubmissionStatus(ctx.supabase, ctx.companyId, period, statusJson)
return NextResponse.json({ data })
},
},
@@ -0,0 +1,118 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { AgiSubmissionState } from '@/lib/salary/agi-submission-state'
/**
* The AGI submission record the run page renders, read from the two places
* the receipt can live.
*
* `agi_submission_{period}` in extension_data is the in-flight cache: it
* carries the underlag/signing-link states and, on the interactive
* "Hämta kvittens" path, the signed receipt. The kvittens reconciliation
* (agi-kvittens-reconcile.ts, driven by the kvittens cron and the
* post-connect refresh) deliberately deletes that cache when it promotes the
* declaration, so no stale "awaiting signature" view survives; it writes the
* receipt to `agi_declarations` (kvittensnummer, submitted_at, response_data)
* instead. Since the cron runs every 15 minutes while the panel only polls
* three times after the signing link is created, that is the NORMAL outcome
* for anyone who signs at an unhurried pace: without this fallback the
* kvittensnummer, signatory and signing time are stored but never shown
* (#1597). Together they form the verification chain for the filing
* (BFL 5 kap 6§), so the panel must show them regardless of who fetched them.
*/
/** Columns the fallback reads from agi_declarations. */
export interface AgiDeclarationReceiptRow {
salary_run_id?: string | null
status: string
kvittensnummer: string | null
submitted_at: string | null
response_data?: {
signeradAv?: string | null
signeradTid?: string | null
submittedAtEstimated?: boolean | null
} | null
}
/** Redovisningsperiod as the extension keys it: YYYYMM. */
const PERIOD_RE = /^\d{6}$/
/**
* Parse the cached `agi_submission_{period}` value. The extension stores it
* as a JSON string; a corrupt value reads as no record rather than a 500.
*/
export function parseCachedAgiSubmission(cached: unknown): AgiSubmissionState | null {
if (!cached) return null
if (typeof cached === 'object') return cached as AgiSubmissionState
if (typeof cached !== 'string') return null
try {
const parsed = JSON.parse(cached) as unknown
return parsed && typeof parsed === 'object' ? (parsed as AgiSubmissionState) : null
} catch {
return null
}
}
/**
* Build a `signed` submission record from the agi_declarations row, or null
* when the row carries no receipt (still generated / pending_signature).
*
* Deliberately no `salaryRunId`: regenerating the AGI for a correction
* repoints the period's single row (UNIQUE per company+period) at the
* correction run while the stored kvittens still belongs to the original.
* Trusting the column would render the correction as filed with the
* superseded receipt. Ownership is instead decided by
* `resolveRunAgiSubmission` from `signeradTid` / `submittedAt` against the
* run's own agi_submitted_at stamp (written from the same value) and from
* `updatedAt` = the moment the receipt was recorded, which predates any
* later correction's XML.
*/
export function agiSubmissionFromDeclaration(
row: AgiDeclarationReceiptRow | null | undefined,
): AgiSubmissionState | null {
if (!row?.kvittensnummer) return null
if (row.status !== 'submitted' && row.status !== 'accepted') return null
const response = row.response_data ?? null
const signeradTid = response?.signeradTid ?? undefined
const record: AgiSubmissionState = {
status: 'signed',
kvittensnummer: row.kvittensnummer,
// signeradTid absent means the stamp is our reconciliation-time upper
// bound, not Skatteverket's signing moment; the flag makes the panel say
// so even for receipts recorded before the flag existed.
submittedAtEstimated: response?.submittedAtEstimated === true || !signeradTid,
source: 'declaration',
}
if (response?.signeradAv) record.signeradAv = response.signeradAv
if (signeradTid) record.signeradTid = signeradTid
if (row.submitted_at) {
record.submittedAt = row.submitted_at
record.updatedAt = row.submitted_at
}
return record
}
/**
* The submission record for a period: the cache when present (it is the only
* place the in-flight states live and, for the interactive path, the receipt
* too), otherwise the receipt recorded on agi_declarations.
*/
export async function readAgiSubmissionStatus(
supabase: SupabaseClient,
companyId: string,
period: string,
cached: unknown,
): Promise<AgiSubmissionState | null> {
const fromCache = parseCachedAgiSubmission(cached)
if (fromCache) return { ...fromCache, source: 'cache' }
if (!PERIOD_RE.test(period)) return null
const { data } = await supabase
.from('agi_declarations')
.select('salary_run_id, status, kvittensnummer, submitted_at, response_data')
.eq('company_id', companyId)
.eq('period_year', parseInt(period.slice(0, 4), 10))
.eq('period_month', parseInt(period.slice(4, 6), 10))
.maybeSingle()
return agiSubmissionFromDeclaration((data as AgiDeclarationReceiptRow | null) ?? null)
}