diff --git a/DECISIONS.md b/DECISIONS.md index 63cd023c..f8481f7c 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1061,3 +1061,4 @@ One line per decision: `[YYYY-MM-DD] : `. 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). diff --git a/components/salary/AGIPanel.tsx b/components/salary/AGIPanel.tsx index 3ebb3746..01019a10 100644 --- a/components/salary/AGIPanel.tsx +++ b/components/salary/AGIPanel.tsx @@ -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) { {/* 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 && (
@@ -1000,12 +1011,19 @@ export function AGIPanel(props: AGIPanelProps) {

{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 ?? '' }, + )}

)}
diff --git a/extensions/general/mcp-server/__tests__/skatteverket-tools.test.ts b/extensions/general/mcp-server/__tests__/skatteverket-tools.test.ts index 8d2684ec..8a12724b 100644 --- a/extensions/general/mcp-server/__tests__/skatteverket-tools.test.ts +++ b/extensions/general/mcp-server/__tests__/skatteverket-tools.test.ts @@ -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 | 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, { diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 3c1bace9..2914eed6 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -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 diff --git a/extensions/general/skatteverket/__tests__/agi-submission-status.test.ts b/extensions/general/skatteverket/__tests__/agi-submission-status.test.ts new file mode 100644 index 00000000..84fda20d --- /dev/null +++ b/extensions/general/skatteverket/__tests__/agi-submission-status.test.ts @@ -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 = {} + 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() + }) +}) diff --git a/extensions/general/skatteverket/index.ts b/extensions/general/skatteverket/index.ts index 224b5abc..56c60df6 100644 --- a/extensions/general/skatteverket/index.ts +++ b/extensions/general/skatteverket/index.ts @@ -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(`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 }) }, }, diff --git a/extensions/general/skatteverket/lib/agi-submission-status.ts b/extensions/general/skatteverket/lib/agi-submission-status.ts new file mode 100644 index 00000000..ef549bfb --- /dev/null +++ b/extensions/general/skatteverket/lib/agi-submission-status.ts @@ -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 { + 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) +} diff --git a/lib/salary/__tests__/agi-submission-state.test.ts b/lib/salary/__tests__/agi-submission-state.test.ts index c540c90e..3b6371ef 100644 --- a/lib/salary/__tests__/agi-submission-state.test.ts +++ b/lib/salary/__tests__/agi-submission-state.test.ts @@ -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 diff --git a/lib/salary/agi-submission-state.ts b/lib/salary/agi-submission-state.ts index 7c0b1cfe..21afb9d4 100644 --- a/lib/salary/agi-submission-state.ts +++ b/lib/salary/agi-submission-state.ts @@ -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 } diff --git a/messages/en.json b/messages/en.json index 5e9ccc47..13a9bbad 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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." }, diff --git a/messages/sv.json b/messages/sv.json index 00c5c1b0..e63fc695 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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." },