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
+1
View File
@@ -1061,3 +1061,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-18] IBAN prefill reads only enabled, still-connected SEK cash_accounts: disconnect keeps rows (bank_connection_id nulled) and the connect picker mirrors deselected accounts, so an unfiltered read could offer a closed or third-party IBAN as invoice payee (skeptic refutation on PR #1695).
[2026-08-18] Editor PDF preview (#1686) recomputes ROT/RUT server-side from the posted lines with the same helpers as build-invoice-write.ts (computeDeduction / computeInvoiceDeductionTotal, base inkl. moms at the rendered rate, invoice-doc only) and resolves the masked personnummer the same way (typed value, else an individual customer's kundkort personnummer): the client is not trusted with the deduction math, and the preview must state the same avdrag row, info box and "Att betala" as the invoice that gets created. The editor now also posts deduction_personnummer / deduction_housing_designation to the preview route, only when a line claims a deduction (same privacy rule as buildInvoiceWritePayload). Swish QR amount in the preview follows buildSwishQrDataUrl, fixed separately in #1685.
[2026-08-18] Skatteverket read data is visible to every company member, no new role gate (#1673): token rows are per (user, company) but the fetched skattekonto/declaration data belongs to the company, and viewers already read `skattekonto_transactions` and the local snapshot with no role check; membership (dispatcher-resolved ctx.companyId + company-scoped SELECT policy on `skatteverket_tokens`) is the gate. Reads resolve the caller's own token first, then the most recently issued active token of any member (all rows ordered, never `.maybeSingle()`, which errored once two members had connected). Writes (moms utkast/las/submit, AGI submit/spara/las, connect/disconnect, /status) stay on the caller's own token: BankID signing is personal.
[2026-08-18] AGI receipt fallback (#1597): GET /agi/status serves the signed record from agi_declarations (kvittensnummer, response_data.signeradAv/signeradTid, submitted_at) only when the agi_submission_{period} cache is absent, and the declaration-sourced record deliberately carries NO salaryRunId: the period row is UNIQUE per company+period and regenerating a correction repoints its salary_run_id at the correction run while the stored kvittens still belongs to the original, so trusting the column would render the correction as filed with a superseded receipt. Ownership rests on signeradTid/submittedAt vs the run's agi_submitted_at stamp (same value) plus updatedAt = submitted_at, which predates any later correction's XML. Cache present still wins because it is the only place the in-flight states live. Rejected: a second client fetch in AGIPanel (two sources of truth for one card) and merging both records in the route (mixes another declaration's fields into an in-flight state).
+28 -10
View File
@@ -935,8 +935,17 @@ export function AGIPanel(props: AGIPanelProps) {
const forcedAdvanced = draftIsStale || underlagRejected
const advancedOpen = showAdvanced || forcedAdvanced
const signedAtRaw = runSubmission?.signeradTid ?? agiSubmittedAt ?? null
const signedAtRaw =
runSubmission?.signeradTid ?? runSubmission?.submittedAt ?? agiSubmittedAt ?? null
const signedAtText = signedAtRaw ? new Date(signedAtRaw).toLocaleString('sv-SE') : null
// A signed record without Skatteverket's signeradTid carries our
// reconciliation-time stamp instead (an upper bound on the signing moment,
// see agi-kvittens-reconcile.ts): say so rather than presenting it as the
// legal signing time. Without any record we cannot tell and print the run
// stamp as before.
const signedAtEstimated =
runSubmission?.status === 'signed' &&
(runSubmission.submittedAtEstimated === true || !runSubmission.signeradTid)
const chainStepState = (step: ChainStep): 'done' | 'running' | 'failed' | 'upcoming' => {
if (!chain) return 'upcoming'
@@ -979,10 +988,12 @@ export function AGIPanel(props: AGIPanelProps) {
<CardContent className="space-y-4">
{/* Filed: the terminal state deserves more than a gray status row.
Kvittensnummer + signature metadata come from the run-scoped
record; a run stamped only via agi_submitted_at (e.g. cron
reconciliation with an evicted cache, or an original whose cached
receipt a later correction has replaced) still gets the card, just
without a number: better than showing another declaration's. */}
record, which /agi/status serves from the in-flight cache or,
once the kvittens reconciliation has deleted that cache, from
agi_declarations (#1597). A run stamped only via agi_submitted_at
(an original whose receipt a later correction has replaced) still
gets the card, just without a number: better than showing another
declaration's. */}
{isSigned && (
<div className="rounded-lg border border-border bg-muted/30 p-4">
<div className="flex items-start gap-3">
@@ -1000,12 +1011,19 @@ export function AGIPanel(props: AGIPanelProps) {
<p className="text-sm text-muted-foreground">
{runSubmission?.signeradAv
? signedAtText
? t('success_card_signed_by_at', {
name: runSubmission.signeradAv,
date: signedAtText,
})
? t(
signedAtEstimated
? 'success_card_signed_by_at_estimated'
: 'success_card_signed_by_at',
{ name: runSubmission.signeradAv, date: signedAtText },
)
: t('success_card_signed_by', { name: runSubmission.signeradAv })
: t('success_card_signed_at', { date: signedAtText ?? '' })}
: t(
signedAtEstimated
? 'success_card_signed_at_estimated'
: 'success_card_signed_at',
{ date: signedAtText ?? '' },
)}
</p>
)}
</div>
@@ -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)
}
@@ -254,6 +254,80 @@ describe('resolveRunAgiSubmission', () => {
})
})
/**
* Once the kvittens reconciliation (cron / post-connect refresh) has promoted
* the declaration it deletes the period cache, and GET /agi/status serves the
* receipt from agi_declarations instead (#1597). That record carries no
* salaryRunId (the period's single row is repointed at a correction run when
* one regenerates its XML), so ownership rests on the timestamps below.
*/
describe('resolveRunAgiSubmission: receipt served from agi_declarations', () => {
const filedRun: AgiFilingRun = {
id: 'r1',
agi_generated_at: '2026-07-13T09:00:00Z',
agi_submitted_at: '2026-07-14T08:30:00+00:00',
}
it('the run whose stamp matches signeradTid owns the receipt with its metadata', () => {
const record: AgiSubmissionState = {
status: 'signed',
kvittensnummer: 'K1',
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',
}
expect(resolveRunAgiSubmission(filedRun, record)).toBe(record)
expect(resolveRunAgiKvittensnummer(filedRun, record)).toBe('K1')
expect(deriveAgiFilingState(filedRun, record)).toBe('signed')
})
it('without signeradTid the reconciliation-time stamp decides ownership', () => {
// Skatteverket omitted signeradTid: the reconciler stamped both the
// declaration and the run row with its own clock, and the record says so.
const estimated: AgiSubmissionState = {
status: 'signed',
kvittensnummer: 'K1',
signeradAv: '191212121212',
submittedAt: '2026-07-14T08:30:00+00:00',
submittedAtEstimated: true,
updatedAt: '2026-07-14T08:30:00+00:00',
source: 'declaration',
}
expect(resolveRunAgiSubmission(filedRun, estimated)).toBe(estimated)
// A stamp from another moment is another declaration's receipt.
const otherRun: AgiFilingRun = { ...filedRun, agi_submitted_at: '2026-06-01T10:00:00Z' }
expect(resolveRunAgiSubmission(otherRun, estimated)).toBeNull()
expect(resolveRunAgiKvittensnummer(otherRun, estimated)).toBeNull()
})
it('a correction run generated after the receipt does not inherit it', () => {
// The declaration row now points at the correction run, which is exactly
// why the record carries no salaryRunId: updatedAt (= submitted_at)
// predates the correction's XML, so it belongs to the original.
const record: AgiSubmissionState = {
status: 'signed',
kvittensnummer: 'K1',
signeradTid: '2026-07-14T08:30:00Z',
submittedAt: '2026-07-14T08:30:00+00:00',
updatedAt: '2026-07-14T08:30:00+00:00',
source: 'declaration',
}
const correction: AgiFilingRun = {
id: 'r2',
agi_generated_at: '2026-07-20T09:00:00Z',
agi_submitted_at: null,
}
expect(resolveRunAgiSubmission(correction, record)).toBeNull()
expect(deriveAgiFilingState(correction, record)).toBe('generated')
// The original keeps its number.
expect(resolveRunAgiKvittensnummer(filedRun, record)).toBe('K1')
})
})
/**
* The salary run page (`app/(dashboard)/salary/runs/[id]/page.tsx`) and the
* AGI panel (`components/salary/AGIPanel.tsx`) read the same period-scoped
+26 -3
View File
@@ -4,7 +4,9 @@
* Combines the run row's authoritative timestamps (agi_generated_at,
* agi_submitted_at) with the Skatteverket extension's per-period submission
* record (extension_data key `agi_submission_{period}`, surfaced via
* GET /api/extensions/ext/skatteverket/agi/status).
* GET /api/extensions/ext/skatteverket/agi/status; once the kvittens
* reconciliation has deleted that cache the same route serves the receipt
* recorded on `agi_declarations`, see `source`).
*
* The submission record is optional: self-hosted installs without the
* Skatteverket extension, users without the capability, and periods that
@@ -40,6 +42,25 @@ export interface AgiSubmissionState {
kvittensnummer?: string
signeradAv?: string
signeradTid?: string
/**
* The moment recorded as the filing time: Skatteverket's signeradTid, or
* the reconciliation time when Skatteverket omitted it. Written by the
* extension's status read from `agi_declarations.submitted_at`, the same
* value that stamps `salary_runs.agi_submitted_at`.
*/
submittedAt?: string
/**
* True when `submittedAt` is our reconciliation-time upper bound rather
* than Skatteverket's signing moment (signeradTid absent), so the UI can
* label it as an estimate instead of as the legal signing time.
*/
submittedAtEstimated?: boolean
/**
* Where the record was read from: the in-flight `agi_submission_{period}`
* cache, or the receipt on `agi_declarations` once the kvittens
* reconciliation has deleted that cache (#1597).
*/
source?: 'cache' | 'declaration'
inlamningId?: number
tillstand?: string
meddelande?: string
@@ -110,9 +131,11 @@ export function resolveRunAgiSubmission(
// 2. This run already carries a kvittens stamp. Its own receipt is the one
// whose signeradTid produced that stamp; a record signed at any other moment
// belongs to another declaration for the same period. Skatteverket may omit
// signeradTid, in which case there is nothing to contradict ownership.
// signeradTid: the record then carries the reconciliation-time stamp as
// `submittedAt` (the same value written to the run row), and a record with
// neither has nothing to contradict ownership.
if (submission.status === 'signed' && submittedAt !== null) {
const signeradTid = epoch(submission.signeradTid)
const signeradTid = epoch(submission.signeradTid ?? submission.submittedAt)
return signeradTid === null || signeradTid === submittedAt ? submission : null
}
+2
View File
@@ -6711,6 +6711,8 @@
"success_card_signed_by": "Signed by {name}",
"success_card_signed_by_at": "Signed by {name}, {date}",
"success_card_signed_at": "Signed {date}",
"success_card_signed_by_at_estimated": "Signed by {name}, approx. {date} (the kvittens carries no exact signing time)",
"success_card_signed_at_estimated": "Signed approx. {date} (the kvittens carries no exact signing time)",
"toast_signed_title": "AGI filed",
"toast_signed_description": "The employer declaration for {period} has been signed and filed with Skatteverket."
},
+2
View File
@@ -6711,6 +6711,8 @@
"success_card_signed_by": "Signerad av {name}",
"success_card_signed_by_at": "Signerad av {name}, {date}",
"success_card_signed_at": "Signerad {date}",
"success_card_signed_by_at_estimated": "Signerad av {name}, ca {date} (kvittensen saknar exakt signeringstid)",
"success_card_signed_at_estimated": "Signerad ca {date} (kvittensen saknar exakt signeringstid)",
"toast_signed_title": "AGI inlämnad",
"toast_signed_description": "Arbetsgivardeklarationen för {period} är signerad och inlämnad till Skatteverket."
},