Files
accounted/extensions/general/skatteverket/lib/token-store.ts
T
Jakob WennbergandClaude Opus 4.7 cd64c0e3fb 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>
2026-04-28 18:26:03 +02:00

168 lines
6.1 KiB
TypeScript

import crypto from 'crypto'
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
import type { SkatteverketTokens } from '../types'
/**
* Encrypted token storage for Skatteverket OAuth2 tokens.
*
* Uses AES-256-GCM with a dedicated encryption key (not the Supabase service
* 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 {
const key = process.env.SKATTEVERKET_TOKEN_ENCRYPTION_KEY
if (!key) throw new Error('SKATTEVERKET_TOKEN_ENCRYPTION_KEY is required')
return crypto.createHash('sha256').update(key).digest()
}
function encrypt(plaintext: string): string {
const key = getEncryptionKey()
const iv = crypto.randomBytes(12)
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
return Buffer.concat([iv, tag, encrypted]).toString('base64url')
}
function decrypt(ciphertext: string): string {
const key = getEncryptionKey()
const combined = Buffer.from(ciphertext, 'base64url')
const iv = combined.subarray(0, 12)
const tag = combined.subarray(12, 28)
const encrypted = combined.subarray(28)
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8')
}
/**
* 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,
userId: string,
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()
// 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')
.delete()
.eq('user_id', userId)
if (deleteError) throw new Error(`Failed to clear existing tokens: ${deleteError.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}`)
}
/**
* Retrieve and decrypt Skatteverket tokens for a user.
* Returns null if no tokens are stored.
*/
export async function getTokens(
_supabase: SupabaseClient,
userId: string
): Promise<SkatteverketTokens | null> {
const db = getServiceClient()
const { data, error } = await db
.from('skatteverket_tokens')
.select('access_token, refresh_token, expires_at, refresh_count, scope')
.eq('user_id', userId)
.single()
if (error || !data) return null
try {
return {
access_token: decrypt(data.access_token),
refresh_token: data.refresh_token ? decrypt(data.refresh_token) : null,
expires_at: new Date(data.expires_at).getTime(),
refresh_count: data.refresh_count ?? 0,
scope: data.scope,
}
} catch {
// Decryption failed — key may have rotated, tokens are invalid
return null
}
}
/**
* Delete stored tokens (disconnect from Skatteverket).
*/
export async function deleteTokens(
_supabase: SupabaseClient,
userId: string
): Promise<void> {
const db = getServiceClient()
await db
.from('skatteverket_tokens')
.delete()
.eq('user_id', userId)
}