Bug/skv connection (#430)

* feat(invoices): implement öresavrundning logic and next invoice number preview

- Added `getDisplayTotal` utility to handle rounding for SEK invoices based on company settings.
- Updated `InvoicesPage` to utilize the new rounding logic when displaying totals.
- Introduced `peek_next_invoice_number` function to allow previewing the next invoice number without consuming the sequence.
- Modified invoice number generation to remove the year prefix and prevent truncation of numbers exceeding three digits.
- Enhanced tests for invoice number generation and rounding functionality to ensure correctness.
- Updated PDF template to reflect new rounding logic for totals and display appropriate values.
- Adjusted company switcher to hide options in sandbox mode.
- Improved error handling and logging in sandbox seeding process.

* fix(skatteverket): remove unused scope labels from SCOPE_LABELS and DEFAULT_SCOPES

* feat(skatteverket): add token revocation handling and disconnect functionality
This commit is contained in:
Mattsson
2026-05-10 14:40:16 +02:00
committed by GitHub
parent 0ee5219b6c
commit d7a7ddae2c
4 changed files with 93 additions and 6 deletions
+45 -3
View File
@@ -10,6 +10,7 @@ import {
Link2Off,
Loader2,
Lock,
PlugZap,
Send,
ShieldAlert,
Unlock,
@@ -249,6 +250,29 @@ export function AGIPanel(props: AGIPanelProps) {
kvittensTimers.current.push(setTimeout(poll, 300_000))
}, [arbetsgivare, period, fetchSubmission, onChange])
const handleDisconnect = useCallback(async () => {
setActionLoading('disconnect')
setError(null)
setSuccess(null)
try {
const res = await fetch('/api/extensions/ext/skatteverket/disconnect', {
method: 'POST',
})
if (!res.ok) {
const json = await res.json().catch(() => ({}))
setError(json.error || `Kunde inte koppla bort (${res.status})`)
return
}
setSuccess('Anslutningen mot Skatteverket har kopplats bort.')
await fetchStatus()
await fetchSubmission()
} catch (e) {
setError(e instanceof Error ? e.message : 'Kunde inte koppla bort')
} finally {
setActionLoading(null)
}
}, [fetchStatus, fetchSubmission])
const handleConnect = () => {
// Open the BankID OAuth flow in a centered popup. The callback page
// detects `window.opener` and posts back a `skatteverket-oauth-success`
@@ -563,9 +587,27 @@ export function AGIPanel(props: AGIPanelProps) {
<CardHeader>
<CardTitle className="flex items-center justify-between text-base">
<span>Arbetsgivardeklaration (AGI)</span>
<span className="flex items-center gap-1 text-xs font-normal text-muted-foreground">
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />
Ansluten
<span className="flex items-center gap-2 text-xs font-normal text-muted-foreground">
<span className="flex items-center gap-1">
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />
Ansluten
</span>
{!readOnly && (
<button
type="button"
onClick={handleDisconnect}
disabled={actionLoading === 'disconnect'}
className="inline-flex items-center gap-1 rounded border border-border px-1.5 py-0.5 text-[11px] font-normal text-muted-foreground transition-colors hover:border-destructive/50 hover:text-destructive disabled:cursor-not-allowed disabled:opacity-50"
title="Koppla bort anslutningen mot Skatteverket"
>
{actionLoading === 'disconnect' ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<PlugZap className="h-3 w-3" />
)}
Koppla bort
</button>
)}
</span>
</CardTitle>
</CardHeader>
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
// Mock the token-store to bypass DB and supply a fresh access token.
const deleteTokensMock = vi.fn()
vi.mock('../lib/token-store', () => ({
getTokens: vi.fn(async () => ({
access_token: 'test-access',
@@ -10,7 +11,7 @@ vi.mock('../lib/token-store', () => ({
scope: 'momsdeklaration',
})),
storeTokens: vi.fn(),
deleteTokens: vi.fn(),
deleteTokens: (...args: unknown[]) => deleteTokensMock(...args),
}))
// Mock oauth so a refresh attempt (shouldn't fire) is harmless.
@@ -66,6 +67,20 @@ describe('skvRequest — error mapping', () => {
}
})
it('maps 401 with "Token has been revoked." body → TOKEN_REVOKED and clears local row', async () => {
deleteTokensMock.mockClear()
mockFetchStatus(401, '{"error":"Token has been revoked."}')
try {
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect(e).toBeInstanceOf(SkatteverketAuthError)
expect((e as SkatteverketAuthError).code).toBe('TOKEN_REVOKED')
expect((e as SkatteverketAuthError).message).toMatch(/återkallat/i)
expect(deleteTokensMock).toHaveBeenCalledWith(fakeSupabase, 'user-1')
}
})
it('maps 401 with WWW-Authenticate insufficient_scope → MISSING_SCOPE', async () => {
mockFetchStatus(401, '', {
'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="agd"',
+1
View File
@@ -1584,6 +1584,7 @@ function handleSkvError(err: unknown): NextResponse {
: err.code === 'SESSION_EXPIRED' || err.code === 'REFRESH_EXHAUSTED' ? 401
: err.code === 'MISSING_SCOPE' ? 401
: err.code === 'TOKEN_CORRUPTED' ? 401
: err.code === 'TOKEN_REVOKED' ? 401
: 403
return NextResponse.json(
@@ -1,7 +1,7 @@
import crypto from 'crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
import { refreshAccessToken } from './oauth'
import { getTokens, storeTokens } from './token-store'
import { getTokens, storeTokens, deleteTokens } from './token-store'
import type { SkatteverketTokens } from '../types'
/**
@@ -244,6 +244,7 @@ export async function skvRequest(
? ` Headers: ${JSON.stringify(skvHeaders)}`
: ''
const bodySuffix = text ? ` Svar: ${text}` : ''
const lower = text.toLowerCase()
// OAuth's standard insufficient_scope marker. SKV sometimes emits this
// as 401 (rather than 403) when the AGI APIGW evaluates scope before
@@ -263,10 +264,33 @@ export async function skvRequest(
)
}
// SKV explicitly declares the token revoked. Body shape observed in
// production: { "error": "Token has been revoked." } with a generic
// `Bearer realm="OAuth2 Client Realm"` challenge header. This is a
// terminal state — the bearer will never come back to life, regardless
// of refresh attempts (refresh_token from the same family is also dead).
// Auto-clear the local row so /status stops claiming we're connected
// and the next interaction forces a clean reconnect. We swallow any
// delete error: even if cleanup fails we still want to surface the
// primary auth error to the user.
if (lower.includes('revoked') || lower.includes('token has been revoked')) {
try {
await deleteTokens(supabase, userId)
} catch (cleanupErr) {
console.error('[skatteverket] failed to clear revoked token row', cleanupErr)
}
throw new SkatteverketAuthError(
'Skatteverket har återkallat anslutningen. Detta händer t.ex. om ' +
'BankID-sessionen avslutats eller om en ny anslutning gjorts från ' +
'en annan enhet. Anslut igen med BankID för att fortsätta.' +
headerSuffix + bodySuffix,
'TOKEN_REVOKED'
)
}
// APIGW subscription / client-credential problems: the gateway responds
// before the bearer is ever evaluated. The user reconnecting won't help
// here — it's an Utvecklarportalen / APIGW configuration issue.
const lower = text.toLowerCase()
const looksLikeApigwIssue =
lower.includes('client_id') ||
lower.includes('client id') ||
@@ -379,6 +403,10 @@ export async function skvRequest(
* NOT_CONNECTED — no tokens stored; user needs to run BankID flow
* SESSION_EXPIRED — 401 from SKV; refresh exhausted or token rejected
* REFRESH_EXHAUSTED — refresh count hit cap (10) before user re-auth
* TOKEN_REVOKED — 401 with "Token has been revoked." body; SKV killed
* the bearer (BankID session ended, parallel connect
* from another device, or auth-code reuse). Local row
* is auto-cleared; user must reconnect with BankID.
* BEHORIGHET_SAKNAS — 403 with "Behörighet" body; user not authorized
* for this company at SKV (firmatecknare / ombud)
* MISSING_SCOPE — 403 with "invalid_scope" body; the stored token
@@ -396,6 +424,7 @@ export class SkatteverketAuthError extends Error {
| 'NOT_CONNECTED'
| 'SESSION_EXPIRED'
| 'REFRESH_EXHAUSTED'
| 'TOKEN_REVOKED'
| 'BEHORIGHET_SAKNAS'
| 'MISSING_SCOPE'
| 'ACCESS_DENIED'