diff --git a/.compliance/ropa.yaml b/.compliance/ropa.yaml index fea96018..2557a49c 100644 --- a/.compliance/ropa.yaml +++ b/.compliance/ropa.yaml @@ -13,15 +13,21 @@ processing_activities: purpose: >- Lämna in lagstadgad arbetsgivardeklaration på individnivå (AGI) till Skatteverket varje månad (Skatteförfarandelagen 26 kap.). Innefattar - huvuduppgift (HU) och individuppgifter (IU) per anställd. + huvuduppgift (HU) och individuppgifter (IU) per anställd. Skatteverkets + kvittens sparas i response_data inklusive signeradAv: personnummer för + den som BankID-signerade inlämningen. Ändamålet är bevis för vem som + juridiskt undertecknade deklarationen samt behandlingshistorik; BFL + 5 kap. 6 § och BFNAR 2013:2 kap. 8 kräver att räkenskapsinformationen + bevarar den faktiska ingivaren, medan själva inlämningen styrs av SFL. lawful_basis: art_6_1_c # legal obligation special_category_basis: null controller: gnubok-tenant processor: anthropic-na # software supplier; Skatteverket is recipient, not processor data_subjects: - employee + - company_signatory # firmatecknare/deklarationsombud (BankID-signerare av kvittensen) data_categories: - - user.government_id # personnummer + - user.government_id # personnummer (anställda i IU; signerarens i response_data.signeradAv) - user.name - user.financial.employment # gross salary, tax withheld, benefits - user.financial.tax # avgifter, sjuklönekostnad @@ -37,10 +43,11 @@ processing_activities: not applicable; no third-country transfer. retention: duration: 7y - basis: bfl_7_kap # BFL 7 kap.: räkenskapsinformation + basis: bfl_7_kap # BFL 7 kap. 2 §: räkenskapsinformation stored_in: - agi_declarations.xml_content - agi_declarations.individuppgifter + - agi_declarations.response_data # SKV kvittens incl. signeradAv (signerarens pnr); access via company-membership RLS - skatteverket_api_audit_log security_measures: - encryption_at_rest_supabase diff --git a/DECISIONS.md b/DECISIONS.md index b7105dbd..2024a601 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -109,3 +109,9 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-12] Kvittens email dedup: notification_log row is now inserted FIRST as an atomic claim (partial unique index 20260712113000 on user_id+reference_id where notification_type = 'skv_kvittens'; 23505 = already claimed, claim released on send failure), and non-uuid reference ids (the VAT cron's composite key) are mapped to a deterministic SHA-256-derived uuid inside kvittens-notification.ts: reference_id is a uuid column, so the old string key silently failed both the dedup select and the insert (22P02); normalizing in-module beats widening the shared column to text or changing the cron's key formula. [2026-07-12] applyPaymentLinkToInvoice (shared send-route payment-link helper) lives in lib/extensions/payment-links.ts, not extensions/general/stripe/lib/payment-links.ts as the review suggested: both send routes reach payment links through the core registry bridge, and a core route importing the Stripe extension directly would break the zero-extensions core build; per-route logging differences are preserved via logPrefix/logContext options. [2026-07-12] Global/app error boundaries recover via a guarded hard `window.location.reload()`, not React `reset()`: reset() re-renders against the same stale server payload/bundle and re-throws, whereas a reload re-runs middleware (fresh rotated Supabase auth cookie) and fetches a fresh bundle (ChunkLoadError after a deploy), matching the browser-navigation self-heal these transients already relied on. A per-path, per-tab-session sessionStorage flag (a monotonic one-shot, not a time window, which could still loop when a failing render takes longer than the window) bounds it to one auto-reload per path so a persistent error shows the manual fallback instead of looping. +[2026-07-13] Skatteverket post-connect refresh (skattekonto sync + kvittens re-check) is AWAITED in the OAuth callback before responding, not deferred via after(): SKV per-flow tokens live ~65 minutes so right-after-consent is the only reliable personal-token window, and awaiting means popup-close = data fresh, so the salary/skattekonto pages' refetch listeners never race a background job. The SKV client's own request timeouts bound the added latency; failures are swallowed (best-effort) so a refresh error can never fail the connect that just succeeded. +[2026-07-13] AGI auto-settlement matches production's split draw as an exact pair (exactly one "Avdragen skatt " == total_tax AND exactly one "Arbetsgivaravgift " == total_avgifter, anchored regexes) rather than summing all rows parsed to the period: summing would let "Beslut ..." correction rows poison the total, and duplicate rows are ambiguous by definition. Anything non-exact falls back to the manual mark-paid button (determinism over inference). +[2026-07-13] Settings Skatteverket connect switched to the AGIPanel popup+postMessage pattern (full-page redirect kept only as popup-blocked fallback): the full-page round-trip leaves SKV pages + the consumed callback URL in browser history directly beneath /settings/tax, so closing settings (router.back()/browser Back) walked into a dead OAuth chain and re-prompted SKV auth. Fixing the history stack in-place (location.replace) only shrinks the problem; not navigating at all removes it. +[2026-07-13] postMessage hardening uses event.source identity (popup handle in a ref) plus a verified rebroadcast CustomEvent ('skatteverket-connection-updated') instead of the nonce the SOC2 finding suggested: a window reference cannot be forged by same-origin scripts, so the source check is strictly stronger than a nonce threaded through the OAuth flow, and pages that never open the popup (salary dashboard) consume the rebroadcast from the component that did verify it. +[2026-07-13] Kvittens cron HTTP response drops companyId per row (GDPR minimization) but keeps declarationId: it is an opaque UUID useless without DB access, the endpoint is cron-secret gated + Cache-Control: no-store, and losing it would make per-run ops debugging blind. The extension_data delete race flagged by the swarm is documented-and-accepted: the agi_submission_ key is period-scoped by design and agi_declarations is UNIQUE per company+period, so no two declarations share a key. +[2026-07-13] signeradAv (personnummer in agi_declarations.response_data) is documented in .compliance/ropa.yaml under the existing agi.submit entry (Art.6(1)(c), BFL 7 kap 2 par retention) rather than moved to a dedicated column with column-level grants: it is part of the SKV kvittens payload preserved verbatim as rakenskapsinformation, and submitted_by is now explicitly documented as the technical submitter with response_data.signeradAv as the authoritative legal signatory. diff --git a/app/(dashboard)/salary/page.tsx b/app/(dashboard)/salary/page.tsx index 31bfe681..f0887ea6 100644 --- a/app/(dashboard)/salary/page.tsx +++ b/app/(dashboard)/salary/page.tsx @@ -117,6 +117,22 @@ export default function SalaryPage() { load() }, [load]) + // Reload after an in-page Skatteverket reconnect. The raw postMessage from + // the BankID popup is only trusted by the component that opened and + // source-verified the popup (SkatteverketConnectPanel / AGIPanel); this + // page never opens the popup itself, so it consumes the verified rebroadcast + // instead. The OAuth callback awaits the skattekonto sync + AGI + // auto-settlement before responding, so this refetch already sees fresh + // tax-payment state instead of racing a background job. + useEffect(() => { + function handleConnectionUpdated() { + load() + } + window.addEventListener('skatteverket-connection-updated', handleConnectionUpdated) + return () => + window.removeEventListener('skatteverket-connection-updated', handleConnectionUpdated) + }, [load]) + // Next open AGI deadline instance - generated by the tax-deadline engine // when the company pays salaries; same source as the /deadlines page. useEffect(() => { diff --git a/app/api/extensions/skatteverket/agi/kvittenser/cron/__tests__/route.test.ts b/app/api/extensions/skatteverket/agi/kvittenser/cron/__tests__/route.test.ts index 2bfc5640..96bb9c77 100644 --- a/app/api/extensions/skatteverket/agi/kvittenser/cron/__tests__/route.test.ts +++ b/app/api/extensions/skatteverket/agi/kvittenser/cron/__tests__/route.test.ts @@ -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) => !('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') }) }) diff --git a/app/api/extensions/skatteverket/agi/kvittenser/cron/route.ts b/app/api/extensions/skatteverket/agi/kvittenser/cron/route.ts index 7208bf99..9e60e4cf 100644 --- a/app/api/extensions/skatteverket/agi/kvittenser/cron/route.ts +++ b/app/api/extensions/skatteverket/agi/kvittenser/cron/route.ts @@ -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 }, + ) } diff --git a/components/salary/AGIPanel.tsx b/components/salary/AGIPanel.tsx index 6edf1b07..8b7bf721 100644 --- a/components/salary/AGIPanel.tsx +++ b/components/salary/AGIPanel.tsx @@ -184,16 +184,28 @@ export function AGIPanel(props: AGIPanelProps) { fetchSubmission() }, [fetchStatus, fetchSubmission]) + // Handle of the OAuth popup opened by handleConnect: used to verify the + // sender identity of incoming postMessages. + const popupRef = useRef(null) + // Listen for OAuth completion from the BankID popup. When the popup posts // back a success/error message we re-fetch status so the panel flips from // "expired" / not-connected to "Ansluten" without a full page reload. useEffect(() => { function handleMessage(event: MessageEvent) { if (event.origin !== window.location.origin) return + // Source-identity check: only the popup this component opened can + // trigger the handler; a window reference cannot be forged by other + // same-origin scripts. + if (!popupRef.current || event.source !== popupRef.current) return if (event.data?.type === 'skatteverket-oauth-success') { setError(null) setSuccess(t('oauth_success')) fetchStatus() + // Verified success: rebroadcast as an internal DOM event so passive + // consumers (e.g. the salary page) can react without trusting raw + // postMessage. + window.dispatchEvent(new CustomEvent('skatteverket-connection-updated')) } else if (event.data?.type === 'skatteverket-oauth-error') { const reason = typeof event.data.reason === 'string' && event.data.reason @@ -366,6 +378,7 @@ export function AGIPanel(props: AGIPanelProps) { 'skatteverket-oauth', `width=${w},height=${h},left=${left},top=${top}`, ) + popupRef.current = popup if (!popup) { // Popup blocked: fall back to a full-page navigation. window.location.href = url diff --git a/components/settings/SkatteverketConnectPanel.tsx b/components/settings/SkatteverketConnectPanel.tsx index 70c0d2e4..6f61ad40 100644 --- a/components/settings/SkatteverketConnectPanel.tsx +++ b/components/settings/SkatteverketConnectPanel.tsx @@ -1,7 +1,7 @@ 'use client' import { useTranslations } from 'next-intl' -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' @@ -38,11 +38,16 @@ export function SkatteverketConnectPanel() { function SkatteverketPersonalConnectionCard() { const t = useTranslations('settings_skatteverket_connect') + // Toast strings shared with TaxSettingsContent's query-param fallback path. + const tOauth = useTranslations('settings_skatteverket') const { toast } = useToast() const hasSkatteverket = useCapability(CAPABILITY.skatteverket) const [status, setStatus] = useState(null) const [loading, setLoading] = useState(true) const [disconnecting, setDisconnecting] = useState(false) + // Handle of the OAuth popup opened by startConnect: used to verify the + // sender identity of incoming postMessages. + const popupRef = useRef(null) // docs: https://www7.skatteverket.se/portal-wapi/open/apier-och-oppna-data/utvecklarportalen/v1/getFile/tjanstebeskrivning-skattekonto-hamta-huvudmans-saldo-och-transaktioner-v101 const SCOPE_LABELS: Record = { @@ -53,7 +58,7 @@ function SkatteverketPersonalConnectionCard() { agd: t('scope_agd'), } - async function loadStatus() { + const loadStatus = useCallback(async () => { setLoading(true) try { const res = await fetch('/api/extensions/ext/skatteverket/status') @@ -68,15 +73,71 @@ function SkatteverketPersonalConnectionCard() { } finally { setLoading(false) } - } + }, []) useEffect(() => { loadStatus() - }, []) + }, [loadStatus]) + + // Listen for OAuth completion from the BankID popup (same pattern as + // AGIPanel): the callback page posts success/error and closes itself, so + // the settings page never navigates and we just re-fetch the status. + useEffect(() => { + function handleMessage(event: MessageEvent) { + if (event.origin !== window.location.origin) return + // Source-identity check: only the popup this component opened can + // trigger the handler; a window reference cannot be forged by other + // same-origin scripts. + if (!popupRef.current || event.source !== popupRef.current) return + if (event.data?.type === 'skatteverket-oauth-success') { + toast({ + title: tOauth('connected_title'), + description: tOauth('connected_description'), + }) + loadStatus() + // Verified success: rebroadcast as an internal DOM event so passive + // consumers (e.g. the salary page) can react without trusting raw + // postMessage. + window.dispatchEvent(new CustomEvent('skatteverket-connection-updated')) + } else if (event.data?.type === 'skatteverket-oauth-error') { + toast({ + title: tOauth('connect_failed_title'), + description: + typeof event.data.reason === 'string' && event.data.reason + ? event.data.reason + : undefined, + variant: 'destructive', + }) + } + } + window.addEventListener('message', handleMessage) + return () => window.removeEventListener('message', handleMessage) + }, [loadStatus, toast, tOauth]) function startConnect() { + // Open the BankID OAuth flow in a centered popup. The callback page + // detects `window.opener`, posts back a message and closes itself: the + // settings page never navigates, so browser history stays clean and + // closing the settings afterwards cannot walk Back into the consumed + // OAuth chain (the "redirected to Skatteverket again" bug). const returnTo = encodeURIComponent('/settings/tax') - window.location.href = `/api/extensions/ext/skatteverket/authorize?return_to=${returnTo}` + const url = `/api/extensions/ext/skatteverket/authorize?return_to=${returnTo}` + const w = 600 + const h = 750 + const left = window.screenX + (window.outerWidth - w) / 2 + const top = window.screenY + (window.outerHeight - h) / 2 + const popup = window.open( + url, + 'skatteverket-oauth', + `width=${w},height=${h},left=${left},top=${top}`, + ) + popupRef.current = popup + if (!popup) { + // Popup blocked: fall back to the full-page flow. The callback then + // lands on /settings/tax?skv_connected=true, handled by + // TaxSettingsContent's query-param effect. + window.location.href = url + } } async function disconnect() { @@ -88,6 +149,9 @@ function SkatteverketPersonalConnectionCard() { if (!res.ok) throw new Error(t('disconnect_failed')) toast({ title: t('toast_disconnected') }) await loadStatus() + // Connection state changed: notify passive consumers via the same + // internal event as a verified OAuth success. + window.dispatchEvent(new CustomEvent('skatteverket-connection-updated')) } catch (err) { toast({ title: t('toast_disconnect_failed'), @@ -235,7 +299,11 @@ function SkatteverketPersonalConnectionCard() { )}
- {(status.expired || status.needsReconsent || !status.canRefresh || !scopes.includes('skattekonto') || !scopes.includes('agd')) && ( + {/* The skattekonto read scope is named `skahmst` in the live grants; + accept the older `skattekonto` name too (mirrors the missing-scope + notice above). Checking only `skattekonto` kept this button + permanently visible on healthy connections. */} + {(status.expired || status.needsReconsent || !status.canRefresh || !(scopes.includes('skahmst') || scopes.includes('skattekonto')) || !scopes.includes('agd')) && (