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
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Delete orphan [SKV-TEST] journal entries from a company.
|
||||
*
|
||||
* Use this to recover from a failed seed-skv-test-data.ts run that inserted
|
||||
* draft entries but failed to commit them (e.g., constraint violation).
|
||||
*
|
||||
* Usage: npx tsx scripts/clean-skv-test-drafts.ts <COMPANY_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 companyId = process.argv[2]
|
||||
if (!companyId) {
|
||||
console.error('Usage: npx tsx scripts/clean-skv-test-drafts.ts <COMPANY_ID>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Find every [SKV-TEST] entry on this company.
|
||||
const { data: entries, error: fetchErr } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id, status, description, voucher_number')
|
||||
.eq('company_id', companyId)
|
||||
.like('description', '[SKV-TEST]%')
|
||||
if (fetchErr) throw new Error(`fetch: ${fetchErr.message}`)
|
||||
|
||||
if (!entries || entries.length === 0) {
|
||||
console.log('No [SKV-TEST] entries found.')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Found ${entries.length} [SKV-TEST] entries:`)
|
||||
for (const e of entries) {
|
||||
console.log(` ${e.status.padEnd(10)} A${e.voucher_number ?? '?'} ${e.description}`)
|
||||
}
|
||||
|
||||
// journal_entry_lines cascades on journal_entries delete, but we still need
|
||||
// to handle the immutability trigger for status='posted'. Drafts only.
|
||||
const drafts = entries.filter(e => e.status === 'draft')
|
||||
const posted = entries.filter(e => e.status !== 'draft')
|
||||
|
||||
if (posted.length > 0) {
|
||||
console.log(`\n⚠ ${posted.length} entries are status='posted' or 'reversed' — those are immutable per BFL.`)
|
||||
console.log(' If you really want to remove them, you have to reverse them first or hard-delete via psql with triggers disabled.')
|
||||
console.log(' Skipping those here.')
|
||||
}
|
||||
|
||||
if (drafts.length === 0) {
|
||||
console.log('\nNo draft entries to delete.')
|
||||
return
|
||||
}
|
||||
|
||||
// BFL compliance trigger blocks DELETE on journal_entries — soft-delete via
|
||||
// status='cancelled' instead. Cancelled entries are filtered out by the VAT
|
||||
// calculator (which only reads 'posted' and 'reversed').
|
||||
console.log(`\nMarking ${drafts.length} draft entries as cancelled...`)
|
||||
const ids = drafts.map(d => d.id)
|
||||
const { error: cancelErr } = await supabase
|
||||
.from('journal_entries')
|
||||
.update({ status: 'cancelled' })
|
||||
.in('id', ids)
|
||||
if (cancelErr) throw new Error(`cancel update: ${cancelErr.message}`)
|
||||
console.log(` ✓ ${drafts.length} drafts cancelled.`)
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err); process.exit(1) })
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* 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)
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* READ-ONLY inspection: shows what Jakob's account looks like in prod and
|
||||
* whether kontrollera can be run safely without seeding any test data.
|
||||
*
|
||||
* Verifies:
|
||||
* 1. The user exists and active_company_id is set
|
||||
* 2. The active company has a usable org_number + entity_type
|
||||
* → so formatRedovisare() can produce the 12-digit SKV redovisare
|
||||
* 3. There's at least one closed fiscal period with VAT-relevant journal
|
||||
* entries we could pick for the kontrollera call
|
||||
* 4. There are no leftover skatteverket_tokens that would surprise us
|
||||
*
|
||||
* Writes nothing. Safe to run against prod.
|
||||
*
|
||||
* Usage: npx tsx scripts/inspect-skv-readiness.ts <EMAIL>
|
||||
*/
|
||||
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
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
|
||||
if (!SUPABASE_URL || !SERVICE_KEY) {
|
||||
console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const supabase = createClient(SUPABASE_URL, SERVICE_KEY, {
|
||||
auth: { persistSession: false },
|
||||
})
|
||||
|
||||
const email = process.argv[2]
|
||||
if (!email) {
|
||||
console.error('Usage: npx tsx scripts/inspect-skv-readiness.ts <EMAIL>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function formatRedovisareLocal(orgNumber: string, entityType: string): string {
|
||||
const digits = orgNumber.replace(/[-\s]/g, '')
|
||||
if (digits.length === 12) return digits
|
||||
if (digits.length !== 10) return `(invalid: ${orgNumber})`
|
||||
if (entityType === 'aktiebolag') return `16${digits}`
|
||||
// EF: prefix century. For births 19XX vs 20XX we'd need the actual logic.
|
||||
const centuryByte = digits.substring(0, 2)
|
||||
const yearByte = parseInt(centuryByte, 10)
|
||||
// Heuristic mirrors lib/skatteverket/format.ts
|
||||
return yearByte < 50 ? `20${digits}` : `19${digits}`
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Inspecting SKV readiness for ${email}\n`)
|
||||
|
||||
// 1. user_id
|
||||
const { data: usersData, error: userErr } = await supabase.auth.admin.listUsers({ page: 1, perPage: 200 })
|
||||
if (userErr) throw new Error(`listUsers: ${userErr.message}`)
|
||||
const user = usersData.users.find(u => u.email === email)
|
||||
if (!user) {
|
||||
console.error(`User ${email} not found.`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`✓ user_id = ${user.id}`)
|
||||
|
||||
// 2. user_preferences
|
||||
const { data: prefs } = await supabase
|
||||
.from('user_preferences')
|
||||
.select('active_company_id')
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
console.log(` active_company_id = ${prefs?.active_company_id ?? '(none)'}`)
|
||||
|
||||
// 3. company memberships
|
||||
const { data: memberships, error: memErr } = await supabase
|
||||
.from('company_members')
|
||||
.select('company_id, role, companies(id, name, org_number, entity_type, archived_at)')
|
||||
.eq('user_id', user.id)
|
||||
if (memErr) throw new Error(`memberships: ${memErr.message}`)
|
||||
if (!memberships?.length) {
|
||||
console.error('User has no company memberships. Sign up flow may be incomplete.')
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`\nCompanies:`)
|
||||
for (const m of memberships) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const c = (Array.isArray(m.companies) ? m.companies[0] : m.companies) as any
|
||||
if (!c) continue
|
||||
const archived = c.archived_at ? ' [ARCHIVED]' : ''
|
||||
const active = c.id === prefs?.active_company_id ? ' ← ACTIVE' : ''
|
||||
const redovisare = c.org_number ? formatRedovisareLocal(c.org_number, c.entity_type) : '(no org_number)'
|
||||
console.log(` ${c.id} ${c.name}${archived}${active}`)
|
||||
console.log(` role=${m.role} org=${c.org_number} entity=${c.entity_type} → redovisare=${redovisare}`)
|
||||
}
|
||||
|
||||
const activeCompany = memberships.find(m => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const c = (Array.isArray(m.companies) ? m.companies[0] : m.companies) as any
|
||||
return c?.id === prefs?.active_company_id
|
||||
})
|
||||
if (!activeCompany) {
|
||||
console.warn('\n⚠ No active company set. UI will pick the first one.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const ac = (Array.isArray(activeCompany.companies) ? activeCompany.companies[0] : activeCompany.companies) as any
|
||||
console.log(`\n--- Active company: ${ac.name} ---`)
|
||||
|
||||
// 4. Fiscal periods with closed status
|
||||
const { data: fps } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, name, period_start, period_end, is_closed')
|
||||
.eq('company_id', ac.id)
|
||||
.order('period_start', { ascending: false })
|
||||
.limit(5)
|
||||
console.log(`\nMost recent fiscal periods:`)
|
||||
if (!fps?.length) {
|
||||
console.log(' (none)')
|
||||
} else {
|
||||
for (const fp of fps) {
|
||||
console.log(` ${fp.period_start} → ${fp.period_end} closed=${fp.is_closed} ${fp.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Count VAT-relevant journal_entry_lines for the most recent month
|
||||
const today = new Date()
|
||||
const lastMonth = new Date(today.getFullYear(), today.getMonth() - 1, 1)
|
||||
const lastMonthEnd = new Date(today.getFullYear(), today.getMonth(), 0)
|
||||
const startStr = `${lastMonth.getFullYear()}-${String(lastMonth.getMonth() + 1).padStart(2, '0')}-01`
|
||||
const endStr = `${lastMonthEnd.getFullYear()}-${String(lastMonthEnd.getMonth() + 1).padStart(2, '0')}-${String(lastMonthEnd.getDate()).padStart(2, '0')}`
|
||||
|
||||
const vatAccounts = ['2611', '2621', '2631', '2614', '2641', '2645', '3001', '3002', '3003', '3308', '3105']
|
||||
const { data: lines, error: lineErr } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('account_number, debit_amount, credit_amount, journal_entries!inner(company_id, entry_date, status)')
|
||||
.in('account_number', vatAccounts)
|
||||
.eq('journal_entries.company_id', ac.id)
|
||||
.eq('journal_entries.status', 'posted')
|
||||
.gte('journal_entries.entry_date', startStr)
|
||||
.lte('journal_entries.entry_date', endStr)
|
||||
.limit(200)
|
||||
if (lineErr) throw new Error(`lines: ${lineErr.message}`)
|
||||
console.log(`\nVAT activity in ${startStr} → ${endStr}: ${lines?.length ?? 0} lines on ${vatAccounts.join('/')}`)
|
||||
|
||||
// 6. Existing skatteverket_tokens?
|
||||
const { data: tokens } = await supabase
|
||||
.from('skatteverket_tokens')
|
||||
.select('user_id, expires_at, refresh_count, scope, created_at')
|
||||
.eq('user_id', user.id)
|
||||
if (tokens?.length) {
|
||||
console.log(`\n⚠ Existing skatteverket_tokens row(s):`)
|
||||
for (const t of tokens) {
|
||||
console.log(` expires_at=${t.expires_at} refresh_count=${t.refresh_count} scope=${t.scope}`)
|
||||
}
|
||||
} else {
|
||||
console.log('\n✓ No existing skatteverket_tokens — clean slate for OAuth.')
|
||||
}
|
||||
|
||||
console.log('\nDone (read-only).')
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* READ-ONLY: inspect the actual schema of public.skatteverket_tokens in prod.
|
||||
* Confirms whether the UNIQUE(user_id) constraint exists and under what name.
|
||||
*/
|
||||
|
||||
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 } },
|
||||
)
|
||||
|
||||
async function main() {
|
||||
// Try a row-count query to see if the table exists at all
|
||||
const { count, error: countErr } = await supabase
|
||||
.from('skatteverket_tokens')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
console.log('Table reachable:', !countErr, 'row count:', count, 'error:', countErr?.message ?? 'none')
|
||||
|
||||
// Use the postgrest schema endpoint to introspect via the OpenAPI spec
|
||||
const { data: openapiResp, error: openapiErr } = await supabase
|
||||
.from('skatteverket_tokens')
|
||||
.select('id, user_id, expires_at, refresh_count, scope')
|
||||
.limit(1)
|
||||
console.log('Sample select error:', openapiErr?.message ?? 'none', '— rows:', openapiResp?.length ?? 0)
|
||||
|
||||
// Fetch from pg_constraint via a dedicated RPC if available, else via raw query
|
||||
// Supabase JS doesn't expose raw SQL, so we use a workaround: try to provoke
|
||||
// the constraint name from the upsert error itself with a dummy row.
|
||||
console.log('\nProbing existing rows to count duplicates per user_id…')
|
||||
const { data: rows, error: rowsErr } = await supabase
|
||||
.from('skatteverket_tokens')
|
||||
.select('id, user_id, created_at')
|
||||
.order('created_at', { ascending: false })
|
||||
if (rowsErr) {
|
||||
console.error('rows fetch failed:', rowsErr.message)
|
||||
return
|
||||
}
|
||||
const byUser = new Map<string, number>()
|
||||
for (const r of rows ?? []) byUser.set(r.user_id, (byUser.get(r.user_id) ?? 0) + 1)
|
||||
console.log(` ${rows?.length ?? 0} total rows across ${byUser.size} distinct user_ids`)
|
||||
const dupes = [...byUser.entries()].filter(([, n]) => n > 1)
|
||||
if (dupes.length) console.log(' duplicates:', dupes)
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err); process.exit(1) })
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* List recent gnubok dev users. Quick sanity-check tool — given a hosted
|
||||
* Supabase project, the easiest way to see who has signed up.
|
||||
*
|
||||
* Usage: npx tsx scripts/list-dev-users.ts
|
||||
*/
|
||||
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
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
|
||||
if (!SUPABASE_URL || !SERVICE_KEY) {
|
||||
console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const supabase = createClient(SUPABASE_URL, SERVICE_KEY, {
|
||||
auth: { persistSession: false },
|
||||
})
|
||||
|
||||
async function main() {
|
||||
const { data, error } = await supabase.auth.admin.listUsers({ page: 1, perPage: 20 })
|
||||
if (error) {
|
||||
console.error('listUsers failed:', error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
if (!data.users.length) {
|
||||
console.log('No users yet. Sign up at /register first.')
|
||||
return
|
||||
}
|
||||
console.log(`Found ${data.users.length} users (most recent first):`)
|
||||
for (const u of data.users) {
|
||||
const last = u.last_sign_in_at ? new Date(u.last_sign_in_at).toISOString() : '(never)'
|
||||
console.log(` ${u.id} ${u.email?.padEnd(40) ?? '(no email)'} last: ${last}`)
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* Seed VAT test data for Skatteverket momsdeklaration kontrollera testing.
|
||||
*
|
||||
* Creates a fiscal period (if missing) and a set of balanced posted journal
|
||||
* entries that exercise every Ruta the calculator populates. After running
|
||||
* this you can call /api/extensions/ext/skatteverket/declaration/validate
|
||||
* for the same period and Skatteverket should return a non-empty
|
||||
* kontrollresultat covering the full SKV 4700 form.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/seed-skv-test-data.ts <COMPANY_ID> <YEAR> <MONTH>
|
||||
*
|
||||
* Example:
|
||||
* npx tsx scripts/seed-skv-test-data.ts 11111111-aaaa-bbbb-cccc-222222222222 2026 3
|
||||
*
|
||||
* Idempotency: every entry's description is prefixed `[SKV-TEST]` so reruns
|
||||
* are easy to identify and clean up:
|
||||
* delete from journal_entries
|
||||
* where company_id = '<id>' and description like '[SKV-TEST]%';
|
||||
*
|
||||
* Requires: SUPABASE_SERVICE_ROLE_KEY in .env.local (already set if you've
|
||||
* been running the dev server).
|
||||
*/
|
||||
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
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
|
||||
if (!SUPABASE_URL || !SERVICE_KEY) {
|
||||
console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const supabase = createClient(SUPABASE_URL, SERVICE_KEY, {
|
||||
auth: { persistSession: false },
|
||||
})
|
||||
|
||||
interface Line {
|
||||
account: string
|
||||
debit?: number
|
||||
credit?: number
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface Scenario {
|
||||
description: string
|
||||
lines: Line[]
|
||||
expectedRutor: string
|
||||
}
|
||||
|
||||
const [, , companyIdArg, yearArg, monthArg] = process.argv
|
||||
|
||||
if (!companyIdArg || !yearArg || !monthArg) {
|
||||
console.error('Usage: npx tsx scripts/seed-skv-test-data.ts <COMPANY_ID> <YEAR> <MONTH>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const companyId = companyIdArg
|
||||
const year = Number(yearArg)
|
||||
const month = Number(monthArg)
|
||||
const entryDate = `${year}-${String(month).padStart(2, '0')}-15`
|
||||
|
||||
// Scenarios chosen to populate every Ruta the calculator now reads from the
|
||||
// ledger. Each one balances debits = credits.
|
||||
const scenarios: Scenario[] = [
|
||||
{
|
||||
description: 'Domestic invoice, 25% rate (Acme Konsult AB)',
|
||||
expectedRutor: 'Ruta 05 + 10',
|
||||
lines: [
|
||||
{ account: '1510', debit: 12500, description: 'Kundfordran' },
|
||||
{ account: '3001', credit: 10000, description: 'Försäljning 25%' },
|
||||
{ account: '2611', credit: 2500, description: 'Utgående moms 25%' },
|
||||
],
|
||||
},
|
||||
{
|
||||
description: 'Domestic invoice, 12% rate (restaurang)',
|
||||
expectedRutor: 'Ruta 05 + 11',
|
||||
lines: [
|
||||
{ account: '1510', debit: 11200 },
|
||||
{ account: '3002', credit: 10000, description: 'Försäljning 12%' },
|
||||
{ account: '2621', credit: 1200, description: 'Utgående moms 12%' },
|
||||
],
|
||||
},
|
||||
{
|
||||
description: 'Domestic invoice, 6% rate (transport)',
|
||||
expectedRutor: 'Ruta 05 + 12',
|
||||
lines: [
|
||||
{ account: '1510', debit: 10600 },
|
||||
{ account: '3003', credit: 10000, description: 'Försäljning 6%' },
|
||||
{ account: '2631', credit: 600, description: 'Utgående moms 6%' },
|
||||
],
|
||||
},
|
||||
{
|
||||
description: 'EU services purchase (German consulting)',
|
||||
expectedRutor: 'Ruta 21 + 30 + 48',
|
||||
lines: [
|
||||
{ account: '4535', debit: 5000, description: 'Inköp tjänster EU 25%' },
|
||||
{ account: '2645', debit: 1250, description: 'Beräknad ingående moms' },
|
||||
{ account: '2614', credit: 1250, description: 'Utgående moms omv. skattskyldighet' },
|
||||
{ account: '2440', credit: 5000, description: 'Leverantörsskuld' },
|
||||
],
|
||||
},
|
||||
{
|
||||
description: 'Non-EU services purchase (Anthropic)',
|
||||
expectedRutor: 'Ruta 22 + 30 + 48',
|
||||
lines: [
|
||||
{ account: '4531', debit: 3000, description: 'Inköp tjänster utanför EU' },
|
||||
{ account: '2645', debit: 750 },
|
||||
{ account: '2614', credit: 750 },
|
||||
{ account: '2440', credit: 3000 },
|
||||
],
|
||||
},
|
||||
{
|
||||
description: 'Domestic mobile reverse charge (electronics >100k)',
|
||||
expectedRutor: 'Ruta 23 + 30 + 48',
|
||||
lines: [
|
||||
{ account: '4415', debit: 100000, description: 'Inköp mobiler omv. skattskyldighet' },
|
||||
{ account: '2647', debit: 25000, description: 'Ingående moms omv. skattskyldighet i SE' },
|
||||
{ account: '2614', credit: 25000 },
|
||||
{ account: '2440', credit: 100000 },
|
||||
],
|
||||
},
|
||||
{
|
||||
description: 'Domestic services reverse charge (byggtjänst)',
|
||||
expectedRutor: 'Ruta 24 + 30 + 48',
|
||||
lines: [
|
||||
{ account: '4425', debit: 8000, description: 'Inköp byggtjänster omv.' },
|
||||
{ account: '2647', debit: 2000 },
|
||||
{ account: '2614', credit: 2000 },
|
||||
{ account: '2440', credit: 8000 },
|
||||
],
|
||||
},
|
||||
{
|
||||
description: 'EU goods sale (intra-community supply, zero-rated)',
|
||||
expectedRutor: 'Ruta 35',
|
||||
lines: [
|
||||
{ account: '1510', debit: 4000 },
|
||||
{ account: '3108', credit: 4000, description: 'Varuförsäljning till EU' },
|
||||
],
|
||||
},
|
||||
{
|
||||
description: 'Export sale (non-EU)',
|
||||
expectedRutor: 'Ruta 36',
|
||||
lines: [
|
||||
{ account: '1510', debit: 5000 },
|
||||
{ account: '3105', credit: 5000, description: 'Varuförsäljning export' },
|
||||
],
|
||||
},
|
||||
{
|
||||
description: 'EU services sale (B2B, buyer self-assesses)',
|
||||
expectedRutor: 'Ruta 39',
|
||||
lines: [
|
||||
{ account: '1510', debit: 8000 },
|
||||
{ account: '3308', credit: 8000, description: 'Tjänsteförsäljning till EU' },
|
||||
],
|
||||
},
|
||||
{
|
||||
description: 'Office supplies purchase (regular input VAT)',
|
||||
expectedRutor: 'Ruta 48',
|
||||
lines: [
|
||||
{ account: '5410', debit: 800, description: 'Förbrukningsinventarier' },
|
||||
{ account: '2641', debit: 200, description: 'Ingående moms 25%' },
|
||||
{ account: '2440', credit: 1000 },
|
||||
],
|
||||
},
|
||||
{
|
||||
description: 'Import from non-EU (paid via Tullverket, VAT to SKV)',
|
||||
expectedRutor: 'Ruta 50 + 60 + 48',
|
||||
lines: [
|
||||
{ account: '4545', debit: 10000, description: 'Beskattningsunderlag import 25%' },
|
||||
{ account: '2641', debit: 2500, description: 'Ingående moms import' },
|
||||
{ account: '2615', credit: 2500, description: 'Utgående moms import 25%' },
|
||||
{ account: '2440', credit: 10000 },
|
||||
],
|
||||
},
|
||||
{
|
||||
description: 'Owner uttag in kind (only for EF) — comment out if AB',
|
||||
expectedRutor: 'Ruta 06 + 10',
|
||||
lines: [
|
||||
{ account: '2013', debit: 1250, description: 'Egna uttag' },
|
||||
{ account: '3401', credit: 1000, description: 'Uttag 25%' },
|
||||
{ account: '2612', credit: 250, description: 'Utgående moms uttag 25%' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
async function ensureFiscalPeriod(): Promise<{ id: string; userId: string }> {
|
||||
const periodStart = `${year}-01-01`
|
||||
const periodEnd = `${year}-12-31`
|
||||
|
||||
// Reuse if it exists
|
||||
const { data: existing, error: fetchErr } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, user_id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('period_start', periodStart)
|
||||
.eq('period_end', periodEnd)
|
||||
.maybeSingle()
|
||||
|
||||
if (fetchErr) throw new Error(`fiscal_periods select: ${fetchErr.message}`)
|
||||
if (existing) return { id: existing.id, userId: existing.user_id }
|
||||
|
||||
const { data: ownerRow, error: ownerErr } = await supabase
|
||||
.from('company_members')
|
||||
.select('user_id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('role', 'owner')
|
||||
.limit(1)
|
||||
.single()
|
||||
if (ownerErr || !ownerRow) {
|
||||
throw new Error(`No owner found for company ${companyId}: ${ownerErr?.message}`)
|
||||
}
|
||||
const userId = ownerRow.user_id
|
||||
|
||||
const { data: created, error: insertErr } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
user_id: userId,
|
||||
name: `${year} (SKV test)`,
|
||||
period_start: periodStart,
|
||||
period_end: periodEnd,
|
||||
})
|
||||
.select('id, user_id')
|
||||
.single()
|
||||
|
||||
if (insertErr || !created) throw new Error(`fiscal_periods insert: ${insertErr?.message}`)
|
||||
console.log(`Created fiscal period ${created.id} (${periodStart} → ${periodEnd})`)
|
||||
return { id: created.id, userId: created.user_id }
|
||||
}
|
||||
|
||||
async function seedScenario(
|
||||
scenario: Scenario,
|
||||
fiscalPeriodId: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
const { description, lines } = scenario
|
||||
const debit = lines.reduce((s, l) => s + (l.debit ?? 0), 0)
|
||||
const credit = lines.reduce((s, l) => s + (l.credit ?? 0), 0)
|
||||
if (Math.abs(debit - credit) > 0.005) {
|
||||
throw new Error(`Scenario unbalanced: ${description} (D=${debit}, C=${credit})`)
|
||||
}
|
||||
|
||||
// 1. Insert draft entry
|
||||
const { data: draft, error: draftErr } = await supabase
|
||||
.from('journal_entries')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
user_id: userId,
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
voucher_number: 0, // placeholder; commit_journal_entry assigns the real one
|
||||
voucher_series: 'A',
|
||||
entry_date: entryDate,
|
||||
description: `[SKV-TEST] ${description}`,
|
||||
source_type: 'manual',
|
||||
status: 'draft',
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (draftErr || !draft) throw new Error(`draft insert (${description}): ${draftErr?.message}`)
|
||||
|
||||
// 2. Insert lines
|
||||
const lineRows = lines.map((l, idx) => ({
|
||||
journal_entry_id: draft.id,
|
||||
account_number: l.account,
|
||||
debit_amount: l.debit ?? 0,
|
||||
credit_amount: l.credit ?? 0,
|
||||
line_description: l.description ?? null,
|
||||
sort_order: idx,
|
||||
}))
|
||||
const { error: linesErr } = await supabase.from('journal_entry_lines').insert(lineRows)
|
||||
if (linesErr) throw new Error(`lines insert (${description}): ${linesErr.message}`)
|
||||
|
||||
// 3. Commit via RPC (assigns sequential voucher number, sets status='posted').
|
||||
// commit_method enum: 'user_accept' | 'bulk_accept' | 'timing_ceiling' |
|
||||
// 'migration' | 'legacy'. 'migration' is the closest fit for synthetic test
|
||||
// data inserted outside the normal user-accept flow.
|
||||
const { data: voucherRow, error: commitErr } = await supabase.rpc('commit_journal_entry', {
|
||||
p_company_id: companyId,
|
||||
p_entry_id: draft.id,
|
||||
p_commit_method: 'migration',
|
||||
p_rubric_version: null,
|
||||
})
|
||||
if (commitErr) throw new Error(`commit (${description}): ${commitErr.message}`)
|
||||
const voucherNumber = Array.isArray(voucherRow) ? voucherRow[0]?.voucher_number : voucherRow?.voucher_number
|
||||
console.log(` ✓ A${voucherNumber} ${description.padEnd(60)} → ${scenario.expectedRutor}`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Seeding SKV test data for company ${companyId}, period ${year}-${String(month).padStart(2, '0')}`)
|
||||
const { id: fiscalPeriodId, userId } = await ensureFiscalPeriod()
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
try {
|
||||
await seedScenario(scenario, fiscalPeriodId, userId)
|
||||
} catch (err) {
|
||||
console.error(` ✗ ${scenario.description}: ${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nDone. Verify in /reports → Momsdeklaration:')
|
||||
console.log(` • Period: ${year}-${String(month).padStart(2, '0')}`)
|
||||
console.log(' • Expected non-zero Rutor: 05, 06, 10, 11, 12, 21, 22, 23, 24, 30, 35, 36, 39, 48, 50, 60')
|
||||
console.log('\nClean up later with:')
|
||||
console.log(` delete from journal_entry_lines where journal_entry_id in (select id from journal_entries where company_id = '${companyId}' and description like '[SKV-TEST]%');`)
|
||||
console.log(` delete from journal_entries where company_id = '${companyId}' and description like '[SKV-TEST]%';`)
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* One-off: set the org_number on Arcim Technology AB.
|
||||
*
|
||||
* Writes ONE row in `companies`. Verifies the company id, name, and current
|
||||
* value before writing. Refuses to overwrite a non-null org_number unless
|
||||
* --force is passed.
|
||||
*
|
||||
* Usage: npx tsx scripts/set-arcim-org-number.ts <COMPANY_ID> <ORG_NUMBER_10DIGIT> [--force]
|
||||
*/
|
||||
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
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
|
||||
if (!SUPABASE_URL || !SERVICE_KEY) {
|
||||
console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const supabase = createClient(SUPABASE_URL, SERVICE_KEY, {
|
||||
auth: { persistSession: false },
|
||||
})
|
||||
|
||||
const [, , companyId, orgNumberRaw, ...flags] = process.argv
|
||||
const force = flags.includes('--force')
|
||||
|
||||
if (!companyId || !orgNumberRaw) {
|
||||
console.error('Usage: npx tsx scripts/set-arcim-org-number.ts <COMPANY_ID> <ORG_NUMBER_10DIGIT> [--force]')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const orgNumber = orgNumberRaw.replace(/[-\s]/g, '')
|
||||
if (!/^\d{10}$/.test(orgNumber)) {
|
||||
console.error(`Org number must be 10 digits (got ${orgNumber.length}: "${orgNumberRaw}")`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { data: before, error: readErr } = await supabase
|
||||
.from('companies')
|
||||
.select('id, name, org_number, entity_type, archived_at')
|
||||
.eq('id', companyId)
|
||||
.single()
|
||||
if (readErr || !before) {
|
||||
console.error(`Company ${companyId} not found: ${readErr?.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`Before:`)
|
||||
console.log(` id: ${before.id}`)
|
||||
console.log(` name: ${before.name}`)
|
||||
console.log(` org_number: ${before.org_number ?? '(null)'}`)
|
||||
console.log(` entity_type: ${before.entity_type}`)
|
||||
console.log(` archived_at: ${before.archived_at ?? '(null)'}`)
|
||||
|
||||
if (before.archived_at) {
|
||||
console.error('\nCompany is archived. Refusing to update.')
|
||||
process.exit(1)
|
||||
}
|
||||
if (before.org_number && before.org_number !== orgNumber && !force) {
|
||||
console.error(`\nCompany already has org_number=${before.org_number}. Refusing to overwrite without --force.`)
|
||||
process.exit(1)
|
||||
}
|
||||
if (before.org_number === orgNumber) {
|
||||
console.log(`\nNo change needed — org_number is already ${orgNumber}.`)
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`\nUpdating org_number → ${orgNumber}`)
|
||||
const { data: after, error: updateErr } = await supabase
|
||||
.from('companies')
|
||||
.update({ org_number: orgNumber })
|
||||
.eq('id', companyId)
|
||||
.select('id, name, org_number, entity_type')
|
||||
.single()
|
||||
if (updateErr || !after) {
|
||||
console.error(`Update failed: ${updateErr?.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`\nAfter:`)
|
||||
console.log(` org_number: ${after.org_number}`)
|
||||
const expectedRedovisare =
|
||||
after.entity_type === 'aktiebolag' ? `16${after.org_number}` : `(EF prefix)`
|
||||
console.log(` → redovisare for SKV API: ${expectedRedovisare}`)
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Update company_settings.org_number for a given company.
|
||||
*
|
||||
* The Skatteverket validate handler reads org_number from company_settings
|
||||
* (not companies), so this is what actually controls the redovisare sent
|
||||
* to SKV. Mirrors set-arcim-org-number.ts but for the settings table.
|
||||
*
|
||||
* Usage: npx tsx scripts/set-company-settings-org.ts <COMPANY_ID> <ORG_NUMBER_10DIGIT> [--force]
|
||||
*/
|
||||
|
||||
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 [, , companyId, orgRaw, ...flags] = process.argv
|
||||
const force = flags.includes('--force')
|
||||
|
||||
if (!companyId || !orgRaw) {
|
||||
console.error('Usage: npx tsx scripts/set-company-settings-org.ts <COMPANY_ID> <ORG_NUMBER_10DIGIT> [--force]')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const org = orgRaw.replace(/[-\s]/g, '')
|
||||
if (!/^\d{10}$/.test(org)) {
|
||||
console.error(`Org number must be 10 digits (got ${org.length})`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { data: before, error: readErr } = await supabase
|
||||
.from('company_settings')
|
||||
.select('id, company_id, org_number, entity_type')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (readErr) {
|
||||
console.error(`read failed: ${readErr.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!before) {
|
||||
// No settings row yet — need to insert. Pull entity_type from companies.
|
||||
const { data: company } = await supabase
|
||||
.from('companies')
|
||||
.select('id, name, entity_type')
|
||||
.eq('id', companyId)
|
||||
.single()
|
||||
if (!company) {
|
||||
console.error(`Company ${companyId} not found`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`No company_settings row for ${company.name} — inserting one.`)
|
||||
const { error: insertErr } = await supabase
|
||||
.from('company_settings')
|
||||
.insert({ company_id: companyId, org_number: org, entity_type: company.entity_type })
|
||||
if (insertErr) {
|
||||
console.error(`insert failed: ${insertErr.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`✓ Inserted company_settings with org_number=${org}, entity_type=${company.entity_type}`)
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Before:`)
|
||||
console.log(` company_id: ${before.company_id}`)
|
||||
console.log(` org_number: ${before.org_number ?? '(null)'}`)
|
||||
console.log(` entity_type: ${before.entity_type}`)
|
||||
|
||||
if (before.org_number === org) {
|
||||
console.log(`\nNo change — already ${org}.`)
|
||||
return
|
||||
}
|
||||
if (before.org_number && !force) {
|
||||
console.error(`\nAlready set to ${before.org_number}. Pass --force to overwrite.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const { data: after, error: updateErr } = await supabase
|
||||
.from('company_settings')
|
||||
.update({ org_number: org })
|
||||
.eq('company_id', companyId)
|
||||
.select('org_number, entity_type')
|
||||
.single()
|
||||
if (updateErr || !after) {
|
||||
console.error(`update failed: ${updateErr?.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
const redovisare =
|
||||
after.entity_type === 'aktiebolag' ? `16${after.org_number}` : `(EF prefix)`
|
||||
console.log(`\nAfter:`)
|
||||
console.log(` org_number: ${after.org_number}`)
|
||||
console.log(` → redovisare for SKV API: ${redovisare}`)
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err); process.exit(1) })
|
||||
@@ -0,0 +1,41 @@
|
||||
-- Set up a gnubok test company that mirrors the testredovisare
|
||||
-- Skatteverket has wired for your test BankID.
|
||||
--
|
||||
-- Replace the three placeholders below before running:
|
||||
-- :user_id — auth.users.id of the dev user signed in to gnubok
|
||||
-- (your gnubok login email's user row)
|
||||
-- :org_number_10digit — the 10-digit form of the testredovisare SKV gave you
|
||||
-- (e.g. if SKV says 165020000013, use 5020000013)
|
||||
-- :entity_type — 'aktiebolag' (16-prefix) or 'enskild_firma' (19/20-prefix)
|
||||
--
|
||||
-- Run with: psql "$DATABASE_URL" -v user_id="'<uuid>'" -v org_number_10digit="'5020000013'" -v entity_type="'aktiebolag'" -f scripts/setup-skv-test-company.sql
|
||||
|
||||
\set ON_ERROR_STOP on
|
||||
begin;
|
||||
|
||||
-- 1. Create the test company.
|
||||
insert into public.companies (name, org_number, entity_type, created_by)
|
||||
values ('SKV Test Company', :org_number_10digit, :entity_type, :user_id)
|
||||
returning id as new_company_id \gset
|
||||
|
||||
-- 2. Add the dev user as owner.
|
||||
insert into public.company_members (company_id, user_id, role)
|
||||
values (:'new_company_id', :user_id, 'owner');
|
||||
|
||||
-- 3. Make this the user's active company.
|
||||
insert into public.user_preferences (user_id, active_company_id)
|
||||
values (:user_id, :'new_company_id')
|
||||
on conflict (user_id) do update set active_company_id = excluded.active_company_id;
|
||||
|
||||
-- 4. Seed the BAS chart of accounts (provides 2611, 2641, 4515, etc.).
|
||||
select public.seed_chart_of_accounts(:'new_company_id', :entity_type);
|
||||
|
||||
commit;
|
||||
|
||||
\echo
|
||||
\echo Test company created:
|
||||
\echo company_id: :new_company_id
|
||||
\echo org_number: :org_number_10digit
|
||||
\echo
|
||||
\echo Next: seed VAT data with
|
||||
\echo npx tsx scripts/seed-skv-test-data.ts :new_company_id 2026 3
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 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)
|
||||
})
|
||||
Reference in New Issue
Block a user