feat(skatteverket): production-ready momsdeklaration submission (#380)
* feat(skatteverket): production-ready momsdeklaration submission
Brings the Skatteverket extension up to a state where it can ship moms
declaration submission to Vercel production. Verified end-to-end against
SKV's Komplett testtjänst — all 8 momsdeklaration operations tested
(kontrollera, spara/hämta/radera utkast, lås/lås upp, hämta inlämnade,
hämta beslutade) plus signing-link return.
Bundles three coherent changes:
1. Skatteverket extension (the main work)
- extensions.config.json: enable `skatteverket`, drop `invoice-inbox`
and `ai-agent` (those were enabled in config but lacked AWS env vars
in prod, so they loaded but failed at runtime)
- lib/reports/vat-declaration.ts: extend ACCOUNT_RUTA to populate
Ruta 06 (uttag 3401–3403), Ruta 20–24 (reverse-charge bases from
4xxx cost accounts), Ruta 50 (import 4545–4547), and Ruta 42
(3404/3994/3980); delete the supplier-type heuristic that made
Ruta 20 and Ruta 23 always 0
- extensions/general/skatteverket/lib/token-store.ts: work around
three real prod schema-drift issues — wrong column on read/delete
(was `company_id`, schema only has `user_id`), missing
UNIQUE(user_id) constraint that makes UPSERT fail (switched to
DELETE+INSERT), missing RLS policies (switched to service-role
client). Refresh path now reuses existing row's company_id when
none is passed.
- extensions/general/skatteverket/index.ts: 9 sites switched from
ctx.companyId to ctx.userId for the token-store key; pass
companyId from the OAuth callback
- extensions/general/skatteverket/types.ts + components/reports/
SkatteverketPanel.tsx: align field names with v1.0.24 RAML
(signeringsLank/kontrollResultat/resultat/kod/status/beskrivning).
Without this, the signing link never displayed.
- SkatteverketPanel: add Lås upp + Radera utkast + Hämta utkast +
Hämta beslut buttons so the full lifecycle is reachable from the UI
- lib/reports/__tests__/vat-declaration.test.ts: rewritten to match
the refactored calculator; new fixtures for cost-account-based
reverse charge (Ruta 20/21/22/23/24), Ruta 50 import, Ruta 06
uttag, Ruta 42 expansion; SKV §4.1.1.4 cross-field contract checks
- supabase/migrations/20260428120000_skatteverket_tokens_user_id_unique.sql:
idempotently adds the missing UNIQUE(user_id) constraint
- scripts/*: dev-only helpers used during the prod-of-test
verification (create test company, seed VAT data, inspect token
state, etc.)
2. Journal-entries cancelled-status filter
- app/api/bookkeeping/journal-entries/route.ts: when no status filter
is supplied, exclude `cancelled` entries by default
- supabase/migrations/20260428153500_journal_entries_with_related_exclude_statuses.sql
3. Swedish e-invoicing skill (reference docs only — no runtime code)
- .claude/skills/swedish-e-invoicing/
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(skatteverket): address PR review findings
- panel: handleFetchDraft read `result.data?.last` (typo) — switched to
`result.data?.locked` to match the field defined in
SkatteverketUtkastResponse and the v1.0.24 RAML. The "(låst)" suffix on
the success message would silently never appear before this fix.
- api-client: getValidToken had no concurrency guard, so two parallel
SKV requests from the same user could both call /token with the same
refresh_token. SKV rotates the refresh_token on first use, so the
second call would 401 with REFRESH_EXHAUSTED-adjacent failures. With
the new 6-button UI on SkatteverketPanel, rapid clicks made this a
realistic trigger. Added an in-process Promise map keyed on userId
that coalesces concurrent refresh attempts; cross-process races are
mitigated by re-reading tokens inside the critical section before
calling refreshAccessToken (if another process refreshed already, we
use the newer token instead of burning the old refresh_token).
- migration 20260428120000: dedup query used `created_at < max(...)`,
which failed to remove duplicates inserted in the same second. The
subsequent ALTER TABLE … ADD CONSTRAINT would then abort. Switched
to ctid (Postgres physical row identifier) to break timestamp ties.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(skatteverket): throw on token-store SELECT error before destructive DELETE
The company_id pre-read in storeTokens used destructuring that discarded
the error field. If the service-role SELECT failed for any reason (network
blip, overloaded DB, transient permissions issue), `existing` became null,
`resolvedCompanyId` stayed undefined, and execution fell through to the
DELETE. The old row got deleted successfully, then the INSERT omitted
company_id and failed with the NOT NULL constraint violation — leaving
the user with no token row at all and forcing a fresh BankID handshake.
Now we capture the SELECT error and throw before the DELETE runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
0363aff1d4
commit
cd64c0e3fb
@@ -148,7 +148,7 @@ export const skatteverketExtension: Extension = {
|
||||
|
||||
try {
|
||||
const tokens = await exchangeCodeForTokens(code, redirectUri)
|
||||
await storeTokens(supabase, user.id, tokens)
|
||||
await storeTokens(supabase, user.id, tokens, companyId)
|
||||
|
||||
// Clean up CSRF state
|
||||
await supabase
|
||||
@@ -186,7 +186,7 @@ export const skatteverketExtension: Extension = {
|
||||
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
||||
}
|
||||
|
||||
const tokens = await getTokens(ctx.supabase, ctx.companyId)
|
||||
const tokens = await getTokens(ctx.supabase, ctx.userId)
|
||||
if (!tokens) {
|
||||
return NextResponse.json({ connected: false })
|
||||
}
|
||||
@@ -213,7 +213,7 @@ export const skatteverketExtension: Extension = {
|
||||
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
||||
}
|
||||
|
||||
await deleteTokens(ctx.supabase, ctx.companyId)
|
||||
await deleteTokens(ctx.supabase, ctx.userId)
|
||||
return NextResponse.json({ success: true })
|
||||
},
|
||||
},
|
||||
@@ -241,7 +241,7 @@ export const skatteverketExtension: Extension = {
|
||||
|
||||
const response = await skvRequest(
|
||||
ctx.supabase,
|
||||
ctx.companyId,
|
||||
ctx.userId,
|
||||
'POST',
|
||||
`/kontrollera/${redovisare}/${redovisningsperiod}`,
|
||||
momsuppgift
|
||||
@@ -287,7 +287,7 @@ export const skatteverketExtension: Extension = {
|
||||
|
||||
const response = await skvRequest(
|
||||
ctx.supabase,
|
||||
ctx.companyId,
|
||||
ctx.userId,
|
||||
'POST',
|
||||
`/utkast/${redovisare}/${redovisningsperiod}`,
|
||||
momsuppgift
|
||||
@@ -337,7 +337,7 @@ export const skatteverketExtension: Extension = {
|
||||
|
||||
const response = await skvRequest(
|
||||
ctx.supabase,
|
||||
ctx.companyId,
|
||||
ctx.userId,
|
||||
'GET',
|
||||
`/utkast/${redovisare}/${redovisningsperiod}`
|
||||
)
|
||||
@@ -376,7 +376,7 @@ export const skatteverketExtension: Extension = {
|
||||
|
||||
const response = await skvRequest(
|
||||
ctx.supabase,
|
||||
ctx.companyId,
|
||||
ctx.userId,
|
||||
'DELETE',
|
||||
`/utkast/${redovisare}/${redovisningsperiod}`
|
||||
)
|
||||
@@ -413,7 +413,7 @@ export const skatteverketExtension: Extension = {
|
||||
|
||||
const response = await skvRequest(
|
||||
ctx.supabase,
|
||||
ctx.companyId,
|
||||
ctx.userId,
|
||||
'PUT',
|
||||
`/las/${redovisare}/${redovisningsperiod}`
|
||||
)
|
||||
@@ -434,7 +434,7 @@ export const skatteverketExtension: Extension = {
|
||||
status: 'draft_locked',
|
||||
redovisare,
|
||||
redovisningsperiod,
|
||||
signeringslank: data.signeringslank,
|
||||
signeringsLank: data.signeringsLank,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
)
|
||||
@@ -460,7 +460,7 @@ export const skatteverketExtension: Extension = {
|
||||
|
||||
const response = await skvRequest(
|
||||
ctx.supabase,
|
||||
ctx.companyId,
|
||||
ctx.userId,
|
||||
'DELETE',
|
||||
`/las/${redovisare}/${redovisningsperiod}`
|
||||
)
|
||||
@@ -504,7 +504,7 @@ export const skatteverketExtension: Extension = {
|
||||
|
||||
const response = await skvRequest(
|
||||
ctx.supabase,
|
||||
ctx.companyId,
|
||||
ctx.userId,
|
||||
'GET',
|
||||
`/inlamnat/${redovisare}/${redovisningsperiod}`
|
||||
)
|
||||
@@ -543,7 +543,7 @@ export const skatteverketExtension: Extension = {
|
||||
|
||||
const response = await skvRequest(
|
||||
ctx.supabase,
|
||||
ctx.companyId,
|
||||
ctx.userId,
|
||||
'GET',
|
||||
`/beslutat/${redovisare}/${redovisningsperiod}`
|
||||
)
|
||||
|
||||
@@ -51,6 +51,15 @@ async function enforceRateLimit(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Coalesce concurrent refresh attempts within this Node.js process. Without
|
||||
// this, two parallel SKV requests from the same user (e.g. rapid UI clicks)
|
||||
// would both call SKV's /token endpoint with the same refresh_token; SKV
|
||||
// rotates that token on first use, so the second call would fail with 401.
|
||||
// Cross-process races (separate Vercel function instances) are mitigated by
|
||||
// the re-read inside the critical section: if another process refreshed
|
||||
// while we waited on the network, we just use that newer token.
|
||||
const refreshInFlight = new Map<string, Promise<string>>()
|
||||
|
||||
/**
|
||||
* Get a valid access token, refreshing if needed.
|
||||
* Throws if no tokens exist or refresh is exhausted.
|
||||
@@ -72,14 +81,39 @@ async function getValidToken(
|
||||
return tokens.access_token
|
||||
}
|
||||
|
||||
// Need refresh
|
||||
// Need refresh — coalesce concurrent attempts.
|
||||
const inFlight = refreshInFlight.get(userId)
|
||||
if (inFlight) return inFlight
|
||||
|
||||
const promise = refreshTokenForUser(supabase, userId)
|
||||
.finally(() => refreshInFlight.delete(userId))
|
||||
refreshInFlight.set(userId, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
async function refreshTokenForUser(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
): Promise<string> {
|
||||
// Re-read after entering the critical section. Another process may have
|
||||
// refreshed while we were waiting; if so, the row now has a new
|
||||
// refresh_token and a future expiry — just hand it back.
|
||||
const tokens = await getTokens(supabase, userId)
|
||||
if (!tokens) {
|
||||
throw new SkatteverketAuthError(
|
||||
'Inte ansluten till Skatteverket. Anslut med BankID först.',
|
||||
'NOT_CONNECTED'
|
||||
)
|
||||
}
|
||||
if (tokens.expires_at > Date.now() + TOKEN_REFRESH_MARGIN_MS) {
|
||||
return tokens.access_token
|
||||
}
|
||||
if (!tokens.refresh_token) {
|
||||
throw new SkatteverketAuthError(
|
||||
'Sessionen har gått ut. Logga in med BankID igen.',
|
||||
'SESSION_EXPIRED'
|
||||
)
|
||||
}
|
||||
|
||||
if (tokens.refresh_count >= MAX_REFRESH_COUNT) {
|
||||
throw new SkatteverketAuthError(
|
||||
'Maximalt antal förnyelser uppnått. Logga in med BankID igen.',
|
||||
@@ -87,14 +121,12 @@ async function getValidToken(
|
||||
)
|
||||
}
|
||||
|
||||
// Refresh — returns NEW refresh_token that must be stored
|
||||
const refreshed = await refreshAccessToken(tokens.refresh_token, tokens.refresh_count)
|
||||
const updatedTokens: SkatteverketTokens = {
|
||||
...refreshed,
|
||||
scope: tokens.scope,
|
||||
}
|
||||
await storeTokens(supabase, userId, updatedTokens)
|
||||
|
||||
return updatedTokens.access_token
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import crypto from 'crypto'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { SkatteverketTokens } from '../types'
|
||||
|
||||
/**
|
||||
@@ -9,8 +9,29 @@ import type { SkatteverketTokens } from '../types'
|
||||
* role key) to encrypt tokens at rest in the skatteverket_tokens table.
|
||||
*
|
||||
* Pattern mirrors lib/auth/oauth-codes.ts but adapted for persistent storage.
|
||||
*
|
||||
* All DB operations route through a service-role client. The original RLS
|
||||
* design (auth.uid() = user_id) is correct, but at least one deployed
|
||||
* environment is missing the INSERT/UPDATE/DELETE policies and the
|
||||
* UNIQUE(user_id) constraint, so user-session writes get rejected. The
|
||||
* service-role client bypasses RLS, and the calling handlers (the OAuth
|
||||
* callback in particular) verify the user identity via cookies before
|
||||
* passing user_id here, so the access-control invariant is upheld at the
|
||||
* application layer.
|
||||
*/
|
||||
|
||||
let _serviceClient: SupabaseClient | null = null
|
||||
function getServiceClient(): SupabaseClient {
|
||||
if (_serviceClient) return _serviceClient
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
const key = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
if (!url || !key) {
|
||||
throw new Error('skatteverket token-store requires NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY')
|
||||
}
|
||||
_serviceClient = createClient(url, key, { auth: { persistSession: false } })
|
||||
return _serviceClient
|
||||
}
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm'
|
||||
|
||||
function getEncryptionKey(): Buffer {
|
||||
@@ -40,32 +61,64 @@ function decrypt(ciphertext: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Store (upsert) Skatteverket tokens for a user.
|
||||
* Store (replace) Skatteverket tokens for a user.
|
||||
* Both access_token and refresh_token are encrypted at rest.
|
||||
*
|
||||
* Implemented as DELETE + INSERT instead of UPSERT because some environments
|
||||
* are missing the UNIQUE(user_id) constraint that ON CONFLICT requires. The
|
||||
* delete-then-insert pattern is safe because OAuth callbacks for a given user
|
||||
* are not concurrent (the user can only sign in with BankID once at a time).
|
||||
*/
|
||||
export async function storeTokens(
|
||||
supabase: SupabaseClient,
|
||||
_supabase: SupabaseClient,
|
||||
userId: string,
|
||||
tokens: SkatteverketTokens
|
||||
tokens: SkatteverketTokens,
|
||||
companyId?: string,
|
||||
): Promise<void> {
|
||||
const encryptedAccess = encrypt(tokens.access_token)
|
||||
const encryptedRefresh = tokens.refresh_token ? encrypt(tokens.refresh_token) : null
|
||||
const db = getServiceClient()
|
||||
|
||||
const { error } = await supabase
|
||||
// The multi-tenant refactor (migration 20260330130000) put a NOT NULL
|
||||
// company_id on every table. Tokens are conceptually user-scoped (one
|
||||
// BankID identity), but the schema requires a company_id. The OAuth
|
||||
// callback passes one explicitly. Token-refresh flows (called from
|
||||
// skvRequest) don't pass one, so before we DELETE the existing row we
|
||||
// remember its company_id and reuse it on INSERT.
|
||||
let resolvedCompanyId = companyId
|
||||
if (!resolvedCompanyId) {
|
||||
const { data: existing, error: selectError } = await db
|
||||
.from('skatteverket_tokens')
|
||||
.select('company_id')
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle()
|
||||
// Throw before the destructive DELETE: a transient read failure here
|
||||
// would otherwise wipe the existing row and then fail the INSERT on the
|
||||
// NOT NULL company_id, leaving the user with no token at all.
|
||||
if (selectError) {
|
||||
throw new Error(`Failed to read existing token row: ${selectError.message}`)
|
||||
}
|
||||
if (existing?.company_id) resolvedCompanyId = existing.company_id
|
||||
}
|
||||
|
||||
const { error: deleteError } = await db
|
||||
.from('skatteverket_tokens')
|
||||
.upsert(
|
||||
{
|
||||
user_id: userId,
|
||||
access_token: encryptedAccess,
|
||||
refresh_token: encryptedRefresh,
|
||||
expires_at: new Date(tokens.expires_at).toISOString(),
|
||||
refresh_count: tokens.refresh_count,
|
||||
scope: tokens.scope,
|
||||
},
|
||||
{ onConflict: 'user_id' }
|
||||
)
|
||||
.delete()
|
||||
.eq('user_id', userId)
|
||||
if (deleteError) throw new Error(`Failed to clear existing tokens: ${deleteError.message}`)
|
||||
|
||||
if (error) throw new Error(`Failed to store tokens: ${error.message}`)
|
||||
const row: Record<string, unknown> = {
|
||||
user_id: userId,
|
||||
access_token: encryptedAccess,
|
||||
refresh_token: encryptedRefresh,
|
||||
expires_at: new Date(tokens.expires_at).toISOString(),
|
||||
refresh_count: tokens.refresh_count,
|
||||
scope: tokens.scope,
|
||||
}
|
||||
if (resolvedCompanyId) row.company_id = resolvedCompanyId
|
||||
|
||||
const { error: insertError } = await db.from('skatteverket_tokens').insert(row)
|
||||
if (insertError) throw new Error(`Failed to store tokens: ${insertError.message}`)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,13 +126,14 @@ export async function storeTokens(
|
||||
* Returns null if no tokens are stored.
|
||||
*/
|
||||
export async function getTokens(
|
||||
supabase: SupabaseClient,
|
||||
_supabase: SupabaseClient,
|
||||
userId: string
|
||||
): Promise<SkatteverketTokens | null> {
|
||||
const { data, error } = await supabase
|
||||
const db = getServiceClient()
|
||||
const { data, error } = await db
|
||||
.from('skatteverket_tokens')
|
||||
.select('access_token, refresh_token, expires_at, refresh_count, scope')
|
||||
.eq('company_id', userId)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (error || !data) return null
|
||||
@@ -102,11 +156,12 @@ export async function getTokens(
|
||||
* Delete stored tokens (disconnect from Skatteverket).
|
||||
*/
|
||||
export async function deleteTokens(
|
||||
supabase: SupabaseClient,
|
||||
_supabase: SupabaseClient,
|
||||
userId: string
|
||||
): Promise<void> {
|
||||
await supabase
|
||||
const db = getServiceClient()
|
||||
await db
|
||||
.from('skatteverket_tokens')
|
||||
.delete()
|
||||
.eq('company_id', userId)
|
||||
.eq('user_id', userId)
|
||||
}
|
||||
|
||||
@@ -38,21 +38,27 @@ export interface SkatteverketMomsuppgift {
|
||||
momsImportUtgaendeLag?: number // Box 62
|
||||
}
|
||||
|
||||
/** Validation result from Skatteverket /kontrollera or /utkast */
|
||||
export interface SkatteverketKontrollresultat {
|
||||
kontroller?: SkatteverketKontroll[]
|
||||
/**
|
||||
* Validation result from Skatteverket /kontrollera or /utkast.
|
||||
* Field names match Momsdeklaration v1.0.24 RAML — note SKV's mixed casing
|
||||
* on `kontrollResultat` and `signeringsLank`.
|
||||
*/
|
||||
export interface SkatteverketKontrollResultat {
|
||||
status?: 'OK' | 'WARNING' | 'ERROR'
|
||||
resultat?: SkatteverketKontroll[]
|
||||
}
|
||||
|
||||
export interface SkatteverketKontroll {
|
||||
id: string // FK001, RK002, etc.
|
||||
typ: 'ERROR' | 'WARNING'
|
||||
text: string
|
||||
kod: string // e.g. "49"
|
||||
status: 'ERROR' | 'WARNING'
|
||||
beskrivning: string
|
||||
}
|
||||
|
||||
/** Response from saving a draft */
|
||||
export interface SkatteverketUtkastResponse {
|
||||
kontrollresultat?: SkatteverketKontrollresultat
|
||||
signeringslank?: string
|
||||
kontrollResultat?: SkatteverketKontrollResultat
|
||||
signeringsLank?: string
|
||||
locked?: boolean
|
||||
}
|
||||
|
||||
/** Response from fetching submitted declarations */
|
||||
@@ -158,7 +164,7 @@ export interface SkatteverketSubmission {
|
||||
status: DeclarationStatus
|
||||
kvittensnummer: string | null
|
||||
signeringslank: string | null
|
||||
kontrollresultat: SkatteverketKontrollresultat | null
|
||||
kontrollresultat: SkatteverketKontrollResultat | null
|
||||
momsuppgift: SkatteverketMomsuppgift
|
||||
created_at: string
|
||||
updated_at: string
|
||||
|
||||
Reference in New Issue
Block a user