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
@@ -8,7 +8,9 @@ import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Gavel,
|
||||
Link2,
|
||||
Link2Off,
|
||||
Loader2,
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
Unlock,
|
||||
Send,
|
||||
ShieldAlert,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import type { VatPeriodType } from '@/types'
|
||||
import { formatRedovisare, formatRedovisningsperiod } from '@/lib/skatteverket/format'
|
||||
@@ -29,10 +32,12 @@ interface SkatteverketStatus {
|
||||
expiresAt?: string
|
||||
}
|
||||
|
||||
// Shape per Skatteverket Momsdeklaration v1.0.24 RAML
|
||||
// (kontrollResultat.resultat[].{kod, status, beskrivning})
|
||||
interface KontrollResult {
|
||||
id: string
|
||||
typ: 'ERROR' | 'WARNING'
|
||||
text: string
|
||||
kod: string
|
||||
status: 'ERROR' | 'WARNING'
|
||||
beskrivning: string
|
||||
}
|
||||
|
||||
interface SkatteverketPanelProps {
|
||||
@@ -141,12 +146,12 @@ function SkatteverketPanelInner({ periodType, year, period, hasData }: Skattever
|
||||
if (result.error) {
|
||||
setError(result.error)
|
||||
} else {
|
||||
const controls = result.data?.kontrollresultat?.kontroller || []
|
||||
const controls: KontrollResult[] = result.data?.kontrollResultat?.resultat || []
|
||||
setKontroller(controls)
|
||||
if (controls.length === 0) {
|
||||
setSuccess('Valideringen godkänd — inga fel eller varningar')
|
||||
} else {
|
||||
const errors = controls.filter((k: KontrollResult) => k.typ === 'ERROR')
|
||||
const errors = controls.filter(k => k.status === 'ERROR')
|
||||
if (errors.length > 0) {
|
||||
setError(`${errors.length} valideringsfel hittades`)
|
||||
} else {
|
||||
@@ -174,9 +179,9 @@ function SkatteverketPanelInner({ periodType, year, period, hasData }: Skattever
|
||||
if (result.error) {
|
||||
setError(result.error)
|
||||
} else {
|
||||
const controls = result.data?.kontrollresultat?.kontroller || []
|
||||
const controls: KontrollResult[] = result.data?.kontrollResultat?.resultat || []
|
||||
setKontroller(controls)
|
||||
const errors = controls.filter((k: KontrollResult) => k.typ === 'ERROR')
|
||||
const errors = controls.filter(k => k.status === 'ERROR')
|
||||
if (errors.length === 0) {
|
||||
setSuccess('Utkast sparat i Eget utrymme hos Skatteverket')
|
||||
} else {
|
||||
@@ -203,8 +208,8 @@ function SkatteverketPanelInner({ periodType, year, period, hasData }: Skattever
|
||||
const result = await res.json()
|
||||
if (result.error) {
|
||||
setError(result.error)
|
||||
} else if (result.data?.signeringslank) {
|
||||
setSigneringslank(result.data.signeringslank)
|
||||
} else if (result.data?.signeringsLank) {
|
||||
setSigneringslank(result.data.signeringsLank)
|
||||
setSuccess('Utkastet är låst. Öppna signeringslänken för att signera med BankID.')
|
||||
}
|
||||
} catch {
|
||||
@@ -263,6 +268,84 @@ function SkatteverketPanelInner({ periodType, year, period, hasData }: Skattever
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteDraft = async () => {
|
||||
setActionLoading('delete')
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/extensions/ext/skatteverket/declaration/draft?redovisare=${encodeURIComponent(
|
||||
await getRedovisare()
|
||||
)}&redovisningsperiod=${getRedovisningsperiod()}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
if (res.status === 204 || res.ok) {
|
||||
setKontroller([])
|
||||
setSigneringslank(null)
|
||||
setSuccess('Utkastet har raderats från Eget utrymme')
|
||||
} else {
|
||||
const result = await res.json().catch(() => ({}))
|
||||
setError(result.error || `Kunde inte radera utkast (${res.status})`)
|
||||
}
|
||||
} catch {
|
||||
setError('Kunde inte radera utkast')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFetchDraft = async () => {
|
||||
setActionLoading('fetchDraft')
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/extensions/ext/skatteverket/declaration/draft?redovisare=${encodeURIComponent(
|
||||
await getRedovisare()
|
||||
)}&redovisningsperiod=${getRedovisningsperiod()}`
|
||||
)
|
||||
const result = await res.json()
|
||||
if (result.error) {
|
||||
setError(result.error)
|
||||
} else if (!result.data) {
|
||||
setSuccess('Inget sparat utkast hittades för perioden')
|
||||
} else {
|
||||
const locked = result.data?.locked ? ' (låst)' : ''
|
||||
const summa = result.data?.momsuppgift?.summaMoms
|
||||
const summaLabel = summa !== undefined ? `, summaMoms = ${formatAmount(summa)}` : ''
|
||||
setSuccess(`Sparat utkast hittades${locked}${summaLabel}`)
|
||||
}
|
||||
} catch {
|
||||
setError('Kunde inte hämta utkast')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFetchDecided = async () => {
|
||||
setActionLoading('fetchDecided')
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/extensions/ext/skatteverket/declaration/decided?redovisare=${encodeURIComponent(
|
||||
await getRedovisare()
|
||||
)}&redovisningsperiod=${getRedovisningsperiod()}`
|
||||
)
|
||||
const result = await res.json()
|
||||
if (result.error) {
|
||||
setError(result.error)
|
||||
} else if (!result.data) {
|
||||
setSuccess('Inget beslut hittades för perioden')
|
||||
} else {
|
||||
const tid = result.data?.beslutadTidpunkt
|
||||
const tidLabel = tid ? ` (beslutad ${new Date(tid).toLocaleDateString('sv-SE')})` : ''
|
||||
setSuccess(`Beslut hittades${tidLabel}`)
|
||||
}
|
||||
} catch {
|
||||
setError('Kunde inte hämta beslutade uppgifter')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to get redovisare from settings
|
||||
const getRedovisare = async (): Promise<string> => {
|
||||
const res = await fetch('/api/settings')
|
||||
@@ -316,8 +399,8 @@ function SkatteverketPanelInner({ periodType, year, period, hasData }: Skattever
|
||||
}
|
||||
|
||||
// Connected — show actions
|
||||
const hasErrors = kontroller.some(k => k.typ === 'ERROR')
|
||||
const hasWarnings = kontroller.some(k => k.typ === 'WARNING')
|
||||
const hasErrors = kontroller.some(k => k.status === 'ERROR')
|
||||
const hasWarnings = kontroller.some(k => k.status === 'WARNING')
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -364,21 +447,21 @@ function SkatteverketPanelInner({ periodType, year, period, hasData }: Skattever
|
||||
</p>
|
||||
{kontroller.map((k, i) => (
|
||||
<div
|
||||
key={`${k.id}-${i}`}
|
||||
key={`${k.kod}-${i}`}
|
||||
className={`flex items-start gap-2 text-sm rounded-lg p-2.5 ${
|
||||
k.typ === 'ERROR'
|
||||
k.status === 'ERROR'
|
||||
? 'bg-destructive/5 text-destructive'
|
||||
: 'bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-200'
|
||||
}`}
|
||||
>
|
||||
{k.typ === 'ERROR' ? (
|
||||
{k.status === 'ERROR' ? (
|
||||
<ShieldAlert className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
)}
|
||||
<div>
|
||||
<span className="font-mono text-xs mr-1.5">{k.id}</span>
|
||||
{k.text}
|
||||
<span className="font-mono text-xs mr-1.5">{k.kod}</span>
|
||||
{k.beskrivning}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -424,7 +507,7 @@ function SkatteverketPanelInner({ periodType, year, period, hasData }: Skattever
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
{/* Forward-lifecycle buttons */}
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -456,44 +539,47 @@ function SkatteverketPanelInner({ periodType, year, period, hasData }: Skattever
|
||||
Spara utkast
|
||||
</Button>
|
||||
|
||||
{!signeringslank ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleLock}
|
||||
disabled={!hasData || hasErrors || actionLoading !== null}
|
||||
className="gap-1.5"
|
||||
title={hasErrors ? 'Valideringsfel måste åtgärdas först' : ''}
|
||||
>
|
||||
{actionLoading === 'lock' ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Lock className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Lås och signera
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleUnlock}
|
||||
disabled={actionLoading !== null}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{actionLoading === 'unlock' ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Unlock className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Lås upp
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleLock}
|
||||
disabled={!hasData || hasErrors || actionLoading !== null}
|
||||
className="gap-1.5"
|
||||
title={hasErrors ? 'Valideringsfel måste åtgärdas först' : ''}
|
||||
>
|
||||
{actionLoading === 'lock' ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Lock className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Lås och signera
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Read-only fetches from Skatteverket. */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleFetchDraft}
|
||||
disabled={actionLoading !== null}
|
||||
className="gap-1.5 text-muted-foreground"
|
||||
title="Hämta sparat utkast från Eget utrymme"
|
||||
>
|
||||
{actionLoading === 'fetchDraft' ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Hämta utkast
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCheckSubmitted}
|
||||
disabled={actionLoading !== null}
|
||||
className="gap-1.5"
|
||||
className="gap-1.5 text-muted-foreground"
|
||||
title="Kontrollera om en signerad deklaration har lämnats in"
|
||||
>
|
||||
{actionLoading === 'check' ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
@@ -502,6 +588,61 @@ function SkatteverketPanelInner({ periodType, year, period, hasData }: Skattever
|
||||
)}
|
||||
Kontrollera inlämning
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleFetchDecided}
|
||||
disabled={actionLoading !== null}
|
||||
className="gap-1.5 text-muted-foreground"
|
||||
title="Hämta Skatteverkets beslut för perioden"
|
||||
>
|
||||
{actionLoading === 'fetchDecided' ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Gavel className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Hämta beslut
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Recovery / cleanup buttons. Always visible when connected so
|
||||
the user can back out of a locked or stale draft state without
|
||||
depending on local UI state surviving a reload. SKV returns
|
||||
404/409 if the action isn't applicable; we surface that as an
|
||||
error message rather than hiding the button. */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleUnlock}
|
||||
disabled={actionLoading !== null}
|
||||
className="gap-1.5 text-muted-foreground"
|
||||
title="Lås upp en låst period så att utkastet kan ändras eller raderas"
|
||||
>
|
||||
{actionLoading === 'unlock' ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Unlock className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Lås upp
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDeleteDraft}
|
||||
disabled={actionLoading !== null}
|
||||
className="gap-1.5 text-muted-foreground"
|
||||
title="Radera sparat utkast från Skatteverkets Eget utrymme"
|
||||
>
|
||||
{actionLoading === 'delete' ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Radera utkast
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Disconnect */}
|
||||
|
||||
Reference in New Issue
Block a user