feat(year-end): administrative undo of executed year-end closing + skatteverket scope fixes (#1081)

* fix(skatteverket): request the ska scope for skattekonto v2

The skattekonto v2 API rejects skahmst-only tokens with 403 "The required
scopes are not authorized" (observed in prod 2026-07-20; no company has
synced since 2026-05-10). The requested `skattekonto` scope is silently
dropped from every grant, while `ska` appears in one real May grant, so
request it too: SKV grants the intersection, so this is harmless if wrong.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(skatteverket): correct the skattekonto scope model around ska

Root cause of the May 10 skattekonto outage, confirmed via git history and
prod token data: the `ska` scope (the interactive skattekonto API's actual
scope, requested since the extension's first commit in March) was removed
by the "remove unused scopes" cleanup in the #431 series. Every token
issued after that hour lacks it and the API answers 403 "The required
scopes are not authorized"; no company has synced since. The May 15 repair
re-added skahmst, which per its tjanstebeskrivning is a different bulk
E-transport service and does not substitute; `skattekonto` is not a real
SKV scope name and is silently dropped from grants.

Follow-up to the ska re-request (cd8f7a30):
- document the confirmed scope model in oauth.ts so ska is never
  "cleaned up" again
- panel missing-scope warning and reconnect-button now gate on ska,
  not skahmst/skattekonto
- scope badge labels: ska takes the saldo & transaktioner label,
  skahmst relabeled as the E-transport file service
- consent-page note covers both terse scope names and says ska is
  required

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(year-end): warn on untaxed profit at verkstall, Swedish readiness messages, always-visible period selector

An aktiebolag could execute year-end with a profit and zero bolagsskatt
booked without any warning (support case: closing moved 592k to 2099
untaxed). The preview now computes bolagsskattMissing (AB + profit + no
89xx account among closed accounts, 8999 excluded) and both the preview
and execute steps render an advisory, bypassable warning.

validateYearEndReadiness messages are now Swedish (the bokslut wizard is
a stays-Swedish surface); the MCP year_end_readiness classifier matches
both the new Swedish strings and the legacy English ones.

The wizard period selector now always renders, keeps a selected-but-
ineligible period selectable, and resets a stale ?period= id from
another company instead of leaving the user stuck on the wrong year.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(year-end): administrative undo of an executed year-end closing

Storno-only reset used when a bokslut was executed prematurely (e.g.
without bolagsskatt) and no arsredovisning exists yet: reverses the next
period's result_appropriation and opening_balance entries, reopens the
period, reverses the closing entry, and detaches closing_entry_id.
Resumable if interrupted midway; attribution per BFL 5 kap 6.

Migration 20260720140000 adds the trigger escape hatch: closing_entry_id
may only change once set when the old closing entry is reversed with a
posted storno chain (status flag alone is forgeable via PostgREST), and
a non-NULL replacement must be a posted year_end entry in the same
period. Covered by a pg-real test.

planResultAppropriation idempotency is now posted-only: a reversed
omforing no longer blocks the re-run from posting a fresh 2099 -> 2098
reclassification (it previously returned null silently, leaving the new
year's equity polluted).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): address CodeRabbit, PR-Agent and compliance findings

- undo script: company_id filters on verify queries, period-scope the
  arsredovisning precondition checks, validate service-key format,
  escalate audit_log insert failure to a hard error (BFNAR 2013:2)
- detach migration: company-scope the storno chain EXISTS, replace the
  em dash in the new error message

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): address round-2 compliance swarm and Swedish review findings

- undo script: require --confirm-url with --commit so an env swap fails
  loud; retry the audit_log insert 3x and direct the operator to insert
  the behandlingshistorik row manually on final failure (BFNAR 2013:2)
- year-end preview: document why resultAccountSummary is a complete 89xx
  scan; warning text now also names periodiseringsfond and
  overavskrivningar as legitimate zero-tax reasons

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-20 16:17:43 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent e2d6c92e3a
commit 4e47335308
19 changed files with 1054 additions and 66 deletions
@@ -4,12 +4,14 @@ import { useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { DestructiveConfirmDialog } from '@/components/ui/destructive-confirm-dialog'
import { Lock, Loader2 } from 'lucide-react'
import { Lock, Loader2, AlertTriangle } from 'lucide-react'
interface ExecuteStepProps {
periodName: string
isRunning: boolean
error: string | null
/** Advisory: AB closing a profit year with no bolagsskatt booked. */
bolagsskattMissing?: boolean
onBack: () => void
onExecute: () => Promise<void>
}
@@ -20,7 +22,7 @@ interface ExecuteStepProps {
* no further entries can be posted to it and the closing transaction is
* immutable.
*/
export function ExecuteStep({ periodName, isRunning, error, onBack, onExecute }: ExecuteStepProps) {
export function ExecuteStep({ periodName, isRunning, error, bolagsskattMissing, onBack, onExecute }: ExecuteStepProps) {
const [confirmOpen, setConfirmOpen] = useState(false)
return (
@@ -48,6 +50,17 @@ export function ExecuteStep({ periodName, isRunning, error, onBack, onExecute }:
Det här går inte att ångra. Om du behöver göra rättelser efter bokslutet använder du
stornering eller bokar i den nya perioden.
</p>
{bolagsskattMissing && (
<div className="rounded-md border border-border p-3 text-sm flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-warning-foreground mt-0.5 shrink-0" />
<p>
Ingen bolagsskatt är bokförd trots att året visar vinst. Om det inte är avsiktligt
(t.ex. underskottsavdrag, periodiseringsfond eller överavskrivningar som nollar det
skattemässiga resultatet), gå tillbaka och boka skatten i dispositionssteget innan
du verkställer.
</p>
</div>
)}
{error && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
{error}
@@ -4,7 +4,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { ArrowRight } from 'lucide-react'
import { ArrowRight, AlertTriangle } from 'lucide-react'
import {
Table,
TableBody,
@@ -73,6 +73,28 @@ export function PreviewStep({ preview, isLoading, error, onBack, onContinue }: P
</CardContent>
</Card>
{preview.bolagsskattMissing && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-warning-foreground" />
Ingen bolagsskatt är bokförd
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-sm">
Året visar vinst men ingen skatt på årets resultat (konto 8910) finns bland de konton
som stängs. Gå tillbaka till dispositionssteget och boka bolagsskatten innan du
verkställer, om inte skattemässigt resultat är noll (t.ex. genom underskottsavdrag,
avsättning till periodiseringsfond eller överavskrivningar).
</p>
<Button variant="outline" size="sm" onClick={onBack}>
Till dispositionssteget
</Button>
</CardContent>
</Card>
)}
{preview.currencyRevaluation && preview.currencyRevaluation.items.length > 0 && (
<Card>
<CardHeader>
@@ -75,6 +75,7 @@ function SkatteverketPersonalConnectionCard() {
const SCOPE_LABELS: Record<string, string> = {
momsdeklaration: t('scope_momsdeklaration'),
inkforetag: t('scope_inkforetag'),
ska: t('scope_ska'),
skahmst: t('scope_skahmst'),
skattekonto: t('scope_skattekonto'),
agd: t('scope_agd'),
@@ -361,7 +362,9 @@ function SkatteverketPersonalConnectionCard() {
</Badge>
))}
</div>
{!scopes.includes('skahmst') && !scopes.includes('skattekonto') && (
{/* `ska` is the scope the interactive skattekonto API enforces;
skahmst (bulk E-transport service) does not substitute for it. */}
{!scopes.includes('ska') && (
<p className="mt-3 text-sm text-foreground">
{t('missing_skattekonto')}
</p>
@@ -381,11 +384,10 @@ function SkatteverketPersonalConnectionCard() {
)}
<div className="flex gap-2 pt-2">
{/* The skattekonto read scope is named `skahmst` in the live grants;
accept the older `skattekonto` name too (mirrors the missing-scope
notice above). Checking only `skattekonto` kept this button
permanently visible on healthy connections. */}
{(status.expired || status.needsReconsent || !status.canRefresh || !(scopes.includes('skahmst') || scopes.includes('skattekonto')) || !scopes.includes('agd')) && (
{/* `ska` gates the interactive skattekonto API (saldo +
transaktioner); a grant without it cannot sync, so offer the
reconnect even while the token is otherwise healthy. */}
{(status.expired || status.needsReconsent || !status.canRefresh || !scopes.includes('ska') || !scopes.includes('agd')) && (
<Button
onClick={startConnect}
disabled={status.disabled || !hasSkatteverket || connecting}