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>
167 lines
6.6 KiB
TypeScript
167 lines
6.6 KiB
TypeScript
/**
|
|
* 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)
|
|
})
|