From aab7e47c356558d0748356fe000e6be815838806 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Mon, 13 Jul 2026 01:40:03 +0200 Subject: [PATCH] Bug/skv auth (#1010) * fix(settings): open Skatteverket connect in a popup and fix the skahmst scope check The full-page OAuth round-trip left Skatteverket's pages and the consumed callback URL in browser history directly beneath /settings/tax, so closing settings walked Back into a dead OAuth chain and re-prompted BankID auth. The connect buttons now use the AGIPanel popup + postMessage pattern (the callback already supports window.opener); the page never navigates and the panel refetches status on success. Full-page navigation remains only as the popup-blocked fallback. Also fixes the reconnect-button condition: it checked for a scope literally named 'skattekonto', but SKV grants 'skahmst', which kept "Anslut igen" permanently visible on healthy connections. Co-Authored-By: Claude Fable 5 * refactor(skatteverket): extract per-declaration AGI kvittens reconciliation Behavior-identical extraction of the kvittens cron's core (fetch kvittens, promote declaration to submitted, stamp salary_runs, clear cached submission, complete deadline, notify) into lib/agi-kvittens-reconcile.ts so the upcoming post-connect refresh can reuse it. Auth-error mapping (needs_reconsent, grant revocation, APIGW config gaps) and run-level logging stay in the cron, which is why SkatteverketAuthError propagates out of the helper on purpose. Co-Authored-By: Claude Fable 5 * fix(skatteverket): auto-settle production-format AGI skattekonto draws The settlement matcher only understood the SKV test environment's single combined row ("Arbetsgivardeklaration YYYYMM"). Production books the draw as two rows with Swedish month names: "Avdragen skatt maj 2026" (= total_tax) and "Arbetsgivaravgift maj 2026" (= total_avgifter), so auto-settlement has never fired against production data and the salary page kept showing paid periods as unpaid. parseAgiPeriod now also reads the month-name form (which additionally lets match suggestions resolve the period on prod rows, including beslut rows). Settlement classifies draws with start-anchored regexes so correction rows like "Beslut 260703 arbetsgivaravgift mars 2026" can never qualify, and settles a period on either the combined row matching the whole declared amount or exactly one tax row + exactly one avgift row matching their respective totals to the ore, dated to the later of the pair. Anything non-exact (partial draws, duplicates) still falls back to the manual mark-paid button. Co-Authored-By: Claude Fable 5 * feat(skatteverket): refresh skattekonto + kvittenser right after OAuth consent Nothing fetched Skatteverket data after a (re)connect: tokens were stored and the user redirected, with the next fetch left to the nightly cron. SKV's per-flow tokens (and refresh tokens) live ~65 minutes, so the crons usually find them dead; right after consent is the one reliable window for a personal-token fetch, which is why reconnecting never made stale numbers recover. The callback now awaits runPostConnectRefresh after storing tokens: a skattekonto sync (upsert + auto-settlement + balance snapshot) plus a kvittens re-check for this company's pending_signature AGI declarations. Awaited on purpose so popup-close means the data is already fresh and UI refetch listeners never race a background job; every step is best-effort and a refresh failure can never fail the connect that just succeeded. The callback's non-popup fallback also switches to window.location.replace so the consumed callback URL (one-shot code + state) drops out of history instead of re-running into a guaranteed CSRF error on Back. Co-Authored-By: Claude Fable 5 * feat(salary): reload the salary dashboard after a Skatteverket reconnect Listens for the BankID popup's skatteverket-oauth-success message and re-runs load(). Because the OAuth callback awaits the skattekonto sync and AGI auto-settlement before responding, the refetch already sees settled tax-payment state: the "Skatt att betala" card flips to paid the moment the popup closes, without leaving the page. Also logs the three decisions behind this series in DECISIONS.md (awaited post-connect refresh over after(), exact-pair settlement over per-period summing, popup flow over in-place history repair). Co-Authored-By: Claude Fable 5 * fix(skatteverket): verify OAuth popup source identity before trusting postMessage The three 'skatteverket-oauth-success' listeners accepted any same-origin message, letting any same-origin script trigger a data reload or a fake success toast (OWASP ASVS V3.2, SOC 2 CC6.1). The two components that open the popup now keep its handle in a ref and require event.source to be that exact window; a window reference cannot be forged by same-origin scripts, which is strictly stronger than a nonce threaded through the OAuth flow. The salary dashboard never opens the popup, so its raw message listener is replaced by a 'skatteverket-connection-updated' CustomEvent dispatched only after a component has source-verified the popup (and after disconnect, so connection-state consumers stay in sync). Also extends the connect consent copy (sv + en) to disclose that connecting immediately fetches skattekonto data and checks pending AGI receipts (GDPR Art.5(1)(b) transparency). Co-Authored-By: Claude Fable 5 * fix(skatteverket): CSP nonce and no-store on OAuth callback, deadline on post-connect refresh Both callback HTML responses now carry a per-response nonce CSP (default-src 'none'; script-src 'nonce-...'; base-uri 'none'; form-action 'none') so injected markup could never execute, plus Cache-Control: no-store because the callback URL carries a one-shot authorization code. The jsLiteral/appUrl injection invariants are documented at the definition site. The awaited post-connect refresh is bounded by a 30-second Promise.race deadline so a hung SKV call cannot hold the OAuth callback open; on timeout the refresh continues best-effort and the user still gets the success response. Refresh failures and timeouts now log through the structured logger with companyId + userId so they are attributable in log aggregation (ASVS V16, ISO 27001 A.8.15, SOC 2 CC8.1). Co-Authored-By: Claude Fable 5 * fix(skatteverket): tenant guard and redacting logs in kvittens reconcile, slim cron response The agi_declarations update now also filters on company_id (ASVS V8.2.1), matching the salary_runs pattern. The reconciler and the cron's error paths log through the structured logger so third-party error strings pass personnummer redaction; uuidKvittens is dropped from log context (GDPR Art.5(1)(f) minimization, declarationId suffices). response_data gains submittedAtEstimated so the signeradTid fallback can never be mistaken for the legal filing time, and the submitted_by comment now states explicitly that it records the technical submitter while response_data.signeradAv is the authoritative legal signatory (BFL 5 kap 6 par, BFNAR 2013:2 kap 8). The cron HTTP response omits companyId per result row and sends Cache-Control: no-store; the extension_data delete documents why the period-scoped cache key needs no declaration-id guard. Cron tests observe the logger via a mock without weakening any assertion. Co-Authored-By: Claude Fable 5 * fix(skatteverket): diagnosable settlement refusals and signeradAv ROPA documentation Settlement refusals (candidates present but amounts mismatch) now emit a structured info log with declared-vs-drawn ore amounts per kind, so a rounding divergence between stored declaration totals and SKV's actual draw is diagnosable instead of silently falling back to the manual button. No transaction texts are logged (they can carry personal data). parseAgiPeriod documents the beslut-row audit: correction rows parse to their period on purpose for match-suggestion boosting; settlement never uses parseAgiPeriod (anchored classifiers + parseNumericAgiPeriod only) and the only callers require an exact amount+side 1630 match first. .compliance/ropa.yaml documents signeradAv (signer personnummer in the SKV kvittens stored in agi_declarations.response_data): lawful basis Art.6(1)(c) via BFL 5 kap 6 par / BFNAR 2013:2 kap 8, 7-year retention per BFL 7 kap 2 par, access via company-membership RLS. DECISIONS.md records the accepted-with-documentation calls from the compliance review. Co-Authored-By: Claude Fable 5 * fix(skatteverket): add 'already_claimed' status to reconcile outcomes and enhance logging for pending lookups --------- Co-authored-by: Claude Fable 5 --- .compliance/ropa.yaml | 13 +- DECISIONS.md | 6 + app/(dashboard)/salary/page.tsx | 16 ++ .../kvittenser/cron/__tests__/route.test.ts | 47 +++- .../skatteverket/agi/kvittenser/cron/route.ts | 251 +++++------------- components/salary/AGIPanel.tsx | 13 + .../settings/SkatteverketConnectPanel.tsx | 80 +++++- .../__tests__/agi-kvittens-reconcile.test.ts | 195 ++++++++++++++ .../__tests__/agi-tax-settlement.test.ts | 164 ++++++++++++ .../__tests__/post-connect-refresh.test.ts | 180 +++++++++++++ .../__tests__/skattekonto-match-agi.test.ts | 29 ++ extensions/general/skatteverket/index.ts | 71 ++++- .../lib/agi-kvittens-reconcile.ts | 236 ++++++++++++++++ .../skatteverket/lib/agi-tax-settlement.ts | 171 ++++++++++-- .../skatteverket/lib/post-connect-refresh.ts | 121 +++++++++ .../skatteverket/lib/skattekonto-match.ts | 69 ++++- .../skatteverket/lib/skattekonto-sync.ts | 6 +- messages/en.json | 2 +- messages/sv.json | 2 +- 19 files changed, 1433 insertions(+), 239 deletions(-) create mode 100644 extensions/general/skatteverket/__tests__/agi-kvittens-reconcile.test.ts create mode 100644 extensions/general/skatteverket/__tests__/post-connect-refresh.test.ts create mode 100644 extensions/general/skatteverket/lib/agi-kvittens-reconcile.ts create mode 100644 extensions/general/skatteverket/lib/post-connect-refresh.ts 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')) && (