cd64c0e3fb
* 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>
318 lines
11 KiB
TypeScript
318 lines
11 KiB
TypeScript
/**
|
|
* 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)
|
|
})
|