Files
accounted/scripts/create-skv-test-company.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

144 lines
5.3 KiB
TypeScript

/**
* Create a dedicated SKV test company in gnubok.
*
* Why this exists: testing the Skatteverket sandbox APIs against Arcim's real
* orgnummer would put real revenue/VAT figures in SKV's test logs under the
* real entity. Better hygiene: a separate gnubok company that uses one of
* SKV's *published* test orgnummer (which are already pre-wired in their
* test registry), seeded with synthetic data only.
*
* Inserts:
* - companies row with name `[TEST] SKV Sandbox`, org_number=1128000013,
* entity_type=aktiebolag, created_by=<user_id>
* - company_members row giving <user_id> owner role
* - company_settings row with the same org_number + entity_type
* - chart_of_accounts seeded via the seed_chart_of_accounts RPC
* - flips user_preferences.active_company_id to the new company so the UI
* starts using it immediately
*
* To revert: delete the company row (CASCADE removes members + settings),
* then update user_preferences.active_company_id back to the previous value.
*
* Usage: npx tsx scripts/create-skv-test-company.ts <USER_ID>
*/
import { createClient } from '@supabase/supabase-js'
import { config } from 'dotenv'
import { resolve } from 'node:path'
config({ path: resolve(process.cwd(), '.env.local') })
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false } },
)
const TEST_NAME = '[TEST] SKV Sandbox'
const TEST_ORG_NUMBER = '1128000013' // → 161128000013, registered for moms in SKV test
const TEST_ENTITY_TYPE = 'aktiebolag'
const userId = process.argv[2]
if (!userId) {
console.error('Usage: npx tsx scripts/create-skv-test-company.ts <USER_ID>')
process.exit(1)
}
async function main() {
// Sanity check: don't create duplicates if the script is rerun.
const { data: existing } = await supabase
.from('companies')
.select('id, name, org_number')
.eq('created_by', userId)
.eq('name', TEST_NAME)
.maybeSingle()
if (existing) {
console.log(`Test company already exists:`)
console.log(` id: ${existing.id}`)
console.log(` name: ${existing.name}`)
console.log(` org_number: ${existing.org_number}`)
console.log(`\nIf you want a fresh one, delete it first:`)
console.log(` delete from companies where id = '${existing.id}';`)
return
}
console.log(`Creating test company for user ${userId}...`)
// 1. Insert the company.
const { data: company, error: companyErr } = await supabase
.from('companies')
.insert({
name: TEST_NAME,
org_number: TEST_ORG_NUMBER,
entity_type: TEST_ENTITY_TYPE,
created_by: userId,
})
.select('id')
.single()
if (companyErr || !company) throw new Error(`companies insert: ${companyErr?.message}`)
const companyId = company.id
console.log(` ✓ companies.id = ${companyId}`)
// 2. Owner membership.
const { error: memberErr } = await supabase
.from('company_members')
.insert({ company_id: companyId, user_id: userId, role: 'owner' })
if (memberErr) throw new Error(`company_members insert: ${memberErr.message}`)
console.log(` ✓ owner membership created`)
// 3. company_settings (the validate handler reads org_number from here).
const { error: settingsErr } = await supabase
.from('company_settings')
.insert({
company_id: companyId,
org_number: TEST_ORG_NUMBER,
entity_type: TEST_ENTITY_TYPE,
})
if (settingsErr) throw new Error(`company_settings insert: ${settingsErr.message}`)
console.log(` ✓ company_settings created`)
// 4. Seed the chart of accounts.
const { error: seedErr } = await supabase.rpc('seed_chart_of_accounts', {
p_company_id: companyId,
p_entity_type: TEST_ENTITY_TYPE,
})
if (seedErr) throw new Error(`seed_chart_of_accounts: ${seedErr.message}`)
console.log(` ✓ chart of accounts seeded`)
// 5. Make this the user's active company so the UI uses it on next load.
const { data: prevPref } = await supabase
.from('user_preferences')
.select('active_company_id')
.eq('user_id', userId)
.maybeSingle()
const previousActive = prevPref?.active_company_id ?? null
const { error: prefErr } = await supabase
.from('user_preferences')
.upsert(
{ user_id: userId, active_company_id: companyId },
{ onConflict: 'user_id' },
)
if (prefErr) throw new Error(`user_preferences upsert: ${prefErr.message}`)
console.log(` ✓ active_company_id flipped to test company`)
console.log(`\nDone.\n`)
console.log(`Test company id: ${companyId}`)
console.log(`Test company name: ${TEST_NAME}`)
console.log(`org_number (10-digit): ${TEST_ORG_NUMBER}`)
console.log(`SKV redovisare (12-digit): 16${TEST_ORG_NUMBER}`)
console.log(`Previous active_company_id: ${previousActive ?? '(none)'}`)
console.log(`\nNext step — seed VAT fixtures for SKV's pre-wired periods:`)
console.log(` npx tsx scripts/seed-skv-test-data.ts ${companyId} 2024 1`)
console.log(` npx tsx scripts/seed-skv-test-data.ts ${companyId} 2024 2`)
console.log(`\nWhen done testing, switch back via the UI's company switcher,`)
console.log(`or run:`)
console.log(` update user_preferences set active_company_id = '${previousActive}' where user_id = '${userId}';`)
}
main().catch(err => {
console.error(err)
process.exit(1)
})