Files
accounted/scripts/test-skv-other-endpoints.ts
T
Jakob Wennberg 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

139 lines
5.1 KiB
TypeScript

/**
* Smoke test for other Skatteverket APIs we have OAuth scopes for, using
* the access token already stored from the moms BankID handshake.
*
* Tests:
* 1. inkomstdeklaration2-4 GET /foretag/inkomstdeklaration/v1/{idPers}/perioder
* Scope: inkforetag (already on token)
* Auth host: peroauth2.test (same as moms)
*
* 2. skattekonto v2 GET /beskattning/skattekonto/v2/skattekonton/{omfragad}/saldo
* Scope: ska (already on token)
* Auth host: peroauth.test (different from moms — empirical risk)
*
* 3. skattekonto v2 GET /skattekonton/{omfragad}/transaktioner
* Same scope as #2
*
* Usage: npx tsx scripts/test-skv-other-endpoints.ts <USER_ID> <REDOVISARE_12DIGIT>
*
* Example: npx tsx scripts/test-skv-other-endpoints.ts \
* 9762dd12-7009-4ba2-aa9f-f9966d53e077 161128000013
*
* READ-ONLY against SKV. Won't modify any SKV state — every operation tested
* is a GET. Won't modify the gnubok DB either; just reads the token.
*/
import { createClient } from '@supabase/supabase-js'
import crypto from 'node:crypto'
import { config } from 'dotenv'
import { resolve } from 'node:path'
config({ path: resolve(process.cwd(), '.env.local') })
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!
const SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY!
const ENCRYPTION_KEY_RAW = process.env.SKATTEVERKET_TOKEN_ENCRYPTION_KEY!
const APIGW_CLIENT_ID = process.env.SKATTEVERKET_APIGW_CLIENT_ID!
const APIGW_CLIENT_SECRET = process.env.SKATTEVERKET_APIGW_CLIENT_SECRET!
if (!SUPABASE_URL || !SERVICE_KEY || !ENCRYPTION_KEY_RAW || !APIGW_CLIENT_ID || !APIGW_CLIENT_SECRET) {
console.error('Missing required env vars in .env.local')
process.exit(1)
}
const [, , userId, redovisare] = process.argv
if (!userId || !redovisare) {
console.error('Usage: npx tsx scripts/test-skv-other-endpoints.ts <USER_ID> <REDOVISARE_12DIGIT>')
process.exit(1)
}
const encryptionKey = crypto.createHash('sha256').update(ENCRYPTION_KEY_RAW).digest()
function decrypt(ciphertext: string): string {
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('aes-256-gcm', encryptionKey, iv)
decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8')
}
async function getAccessToken(): Promise<string> {
const supabase = createClient(SUPABASE_URL, SERVICE_KEY, { auth: { persistSession: false } })
const { data, error } = await supabase
.from('skatteverket_tokens')
.select('access_token, expires_at, scope')
.eq('user_id', userId)
.single()
if (error || !data) throw new Error(`No token row for user ${userId}: ${error?.message}`)
const accessToken = decrypt(data.access_token)
const expiresAt = new Date(data.expires_at)
if (expiresAt.getTime() < Date.now()) {
throw new Error(`Token expired at ${expiresAt.toISOString()}. Re-authorize via the panel.`)
}
console.log(`Token valid until ${expiresAt.toISOString()}, scope = ${data.scope}`)
return accessToken
}
async function callSkv(label: string, url: string, accessToken: string): Promise<void> {
console.log(`\n--- ${label} ---`)
console.log(`GET ${url}`)
const response = await fetch(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Client_Id: APIGW_CLIENT_ID,
Client_Secret: APIGW_CLIENT_SECRET,
skv_client_correlation_id: crypto.randomUUID(),
Accept: 'application/json',
},
})
console.log(`Status: ${response.status} ${response.statusText}`)
const ct = response.headers.get('content-type') ?? ''
const body = await response.text()
if (ct.includes('json')) {
try {
const json = JSON.parse(body)
console.log('Body:', JSON.stringify(json, null, 2))
} catch {
console.log('Body (raw):', body.slice(0, 500))
}
} else {
console.log(`Content-Type: ${ct}`)
console.log('Body (first 300 chars):', body.slice(0, 300))
}
}
async function main() {
const accessToken = await getAccessToken()
// 1. inkomstdeklaration2-4 — same OAuth host as moms, requires `inkforetag` scope
await callSkv(
'inkomstdeklaration2-4 — perioder',
`https://api.test.skatteverket.se/foretag/inkomstdeklaration/v1/${redovisare}/perioder`,
accessToken,
)
// 2. skattekonto v2 — declares peroauth.test (different from moms peroauth2.test).
// Test if our existing token is accepted; 401 here means we'd need a separate handshake.
await callSkv(
'skattekonto v2 — saldo',
`https://api.test.skatteverket.se/beskattning/skattekonto/v2/skattekonton/${redovisare}/saldo`,
accessToken,
)
// 3. skattekonto v2 — transaktioner (only meaningful if #2 worked)
await callSkv(
'skattekonto v2 — transaktioner',
`https://api.test.skatteverket.se/beskattning/skattekonto/v2/skattekonton/${redovisare}/transaktioner`,
accessToken,
)
console.log('\nDone (read-only).')
}
main().catch(err => {
console.error('Error:', err.message)
process.exit(1)
})