From 4e47335308f91bfdbda98ca1d95f0fad87825ad3 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:17:43 +0200 Subject: [PATCH] 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 * 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Fable 5 --- DECISIONS.md | 5 + app/(dashboard)/bookkeeping/year-end/page.tsx | 30 +- .../bookkeeping/year-end/ExecuteStep.tsx | 17 +- .../bookkeeping/year-end/PreviewStep.tsx | 24 +- .../settings/SkatteverketConnectPanel.tsx | 14 +- .../__tests__/year-end-readiness.test.ts | 10 +- extensions/general/mcp-server/server.ts | 21 +- extensions/general/skatteverket/lib/oauth.ts | 19 +- .../__tests__/readiness-aggregator.test.ts | 2 +- .../__tests__/result-appropriation.test.ts | 26 + .../__tests__/year-end-service.test.ts | 108 ++++- .../result-appropriation-service.ts | 12 +- lib/core/bookkeeping/year-end-service.ts | 57 ++- messages/en.json | 5 +- messages/sv.json | 5 +- scripts/undo-year-end-closing.ts | 444 ++++++++++++++++++ ...0000_closing_entry_detach_escape_hatch.sql | 73 +++ tests/pg/closing-entry-detach.pg.test.ts | 242 ++++++++++ types/index.ts | 6 + 19 files changed, 1054 insertions(+), 66 deletions(-) create mode 100644 scripts/undo-year-end-closing.ts create mode 100644 supabase/migrations/20260720140000_closing_entry_detach_escape_hatch.sql create mode 100644 tests/pg/closing-entry-detach.pg.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 591b1311..c3b91bbd 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -236,4 +236,9 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-20] MCP gnubok_book_salary_run walks the whole review->approved->paid->booked chain in ONE staged op instead of mirroring the dashboard's four separate clicks: the human approval of the pending operation (high-risk, confirmed=true) IS the authorization act, and a four-op chain over MCP would just be approval theater. Missing bank details downgrade from overridable block to warnings (dashboard force-approve semantics): the payment-file generators hard-block on them where it matters. [2026-07-20] No gnubok_archive_employee tool: gnubok_update_employee already takes is_active=false (soft-archive, BFL retention) and the v1 REST surface has the DELETE verb; a dedicated tool would only bloat the tools/list budget. [2026-07-20] Booking core extracted to lib/salary/book-run.ts and shared by the dashboard route + book_salary_run executor; the v1 book route intentionally keeps its own strict-mode mirror (optimistic locking, period pre-check, its own envelope) rather than being folded in. +[2026-07-20] Skattekonto sync root cause: the `ska` OAuth scope (the interactive skattekonto API's real scope, requested since the extension's first commit) was removed 2026-05-10 by a "remove unused scopes" cleanup (#431 series); every token issued after that hour gets 403 "The required scopes are not authorized" from the API. skahmst does NOT substitute (separate bulk E-transport service per its tjanstebeskrivning) and `skattekonto` is not a real SKV scope name (silently dropped from grants). Fix = re-request ska; panel scope checks now gate on ska. Kept requesting skahmst+skattekonto too: over-requesting is free, SKV grants the intersection. +[2026-07-20] Missing-bolagsskatt warning lives in the year-end preview (previewYearEndClosing.bolagsskattMissing), not the readiness aggregator: at preflight time tax is legitimately not yet booked (it is booked later in the dispositions step), so an aggregator reminder would always fire and be noise; the preview is computed fresh right before Verkställ. Warning is advisory, never a blocker (zero tax is legit with underskottsavdrag). +[2026-07-20] closing_entry_id made detachable via trigger escape hatch (migration 20260720140000) instead of leaving the link and relaxing app validation: the old trigger made an executed bokslut unrecoverable even pre-arsredovisning. Escape hatch demands the real storno chain (posted storno with reverses_id), not just status='reversed' (forgeable via PostgREST), and any replacement must be a posted year_end entry in the same period. +[2026-07-20] planResultAppropriation idempotency filter narrowed to status='posted': a reversed omforing is storno-cancelled (net zero on 2099) and must not block the re-run after an administrative year-end undo. Trade-off accepted: a user who deliberately reversed the auto omforing and wants 2099 to keep carrying will get it re-posted on the next year-end/catch-up run. +[2026-07-20] Follow-up (not done): delete_last_voucher RPC can delete the closing storno and flip the closing entry back to posted while closing_entry_id is already NULL, leaving an orphaned live closing entry; should refuse to delete stornos of year_end entries. [2026-07-20] Onboarding backdrop reuses marketing-site halftone webp assets copied into public/illustrations/ (not hotlinked, not regenerated): keeps app self-contained and signup->app visually continuous; decorative art uses plain (physics sizes by %, next/image adds nothing for 1-35KB webp). diff --git a/app/(dashboard)/bookkeeping/year-end/page.tsx b/app/(dashboard)/bookkeeping/year-end/page.tsx index a0aea94b..4a3574e0 100644 --- a/app/(dashboard)/bookkeeping/year-end/page.tsx +++ b/app/(dashboard)/bookkeeping/year-end/page.tsx @@ -86,18 +86,37 @@ export default function YearEndPage() { return } const { data } = (await res.json()) as { data: FiscalPeriod[] } + const all = data ?? [] const today = new Date().toISOString().split('T')[0] - const eligible = (data ?? []).filter( + const eligible = all.filter( (p) => !p.is_closed && !p.closing_entry_id && p.period_end <= today, ) // Oldest first: accountants close in order. eligible.sort((a, b) => a.period_start.localeCompare(b.period_start)) if (cancelled) return - setPeriods(eligible) - setHasAnyPeriods((data ?? []).length > 0) - if (!selectedPeriodId && eligible.length > 0) { + setHasAnyPeriods(all.length > 0) + + // The URL ?period= param may point anywhere: at an ineligible period + // (e.g. a year that has not ended) or, after a company switch, at a + // period that does not exist in this company at all. An unknown id is + // reset to the first eligible period; a known-but-ineligible one is + // kept selectable in the dropdown so the user can navigate away from + // it instead of being stuck (the readiness step explains why it + // cannot be closed). + let options = eligible + if (selectedPeriodId) { + const known = all.find((p) => p.id === selectedPeriodId) + if (!known) { + setSelectedPeriodId(eligible.length > 0 ? eligible[0].id : null) + } else if (!eligible.some((p) => p.id === selectedPeriodId)) { + options = [...eligible, known].sort((a, b) => + a.period_start.localeCompare(b.period_start), + ) + } + } else if (eligible.length > 0) { setSelectedPeriodId(eligible[0].id) } + setPeriods(options) } catch { if (!cancelled) setPeriodsError('Kunde inte hämta perioder') } @@ -256,7 +275,7 @@ export default function YearEndPage() { ) )} - {showWizard && periods && periods.length > 1 && step !== 'result' && ( + {showWizard && periods && periods.length > 0 && step !== 'result' && ( @@ -349,6 +368,7 @@ export default function YearEndPage() { periodName={report.period.name} isRunning={executing} error={executeError} + bolagsskattMissing={preview?.bolagsskattMissing ?? false} onBack={() => setStep('preview')} onExecute={executeYearEnd} /> diff --git a/components/bookkeeping/year-end/ExecuteStep.tsx b/components/bookkeeping/year-end/ExecuteStep.tsx index 3cdedb29..91305a84 100644 --- a/components/bookkeeping/year-end/ExecuteStep.tsx +++ b/components/bookkeeping/year-end/ExecuteStep.tsx @@ -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 } @@ -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.

+ {bolagsskattMissing && ( +
+ +

+ 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. +

+
+ )} {error && (
{error} diff --git a/components/bookkeeping/year-end/PreviewStep.tsx b/components/bookkeeping/year-end/PreviewStep.tsx index a06679d9..74bd3d53 100644 --- a/components/bookkeeping/year-end/PreviewStep.tsx +++ b/components/bookkeeping/year-end/PreviewStep.tsx @@ -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 + {preview.bolagsskattMissing && ( + + + + + Ingen bolagsskatt är bokförd + + + +

+ Å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). +

+ +
+
+ )} + {preview.currencyRevaluation && preview.currencyRevaluation.items.length > 0 && ( diff --git a/components/settings/SkatteverketConnectPanel.tsx b/components/settings/SkatteverketConnectPanel.tsx index 67544cf7..81353c5d 100644 --- a/components/settings/SkatteverketConnectPanel.tsx +++ b/components/settings/SkatteverketConnectPanel.tsx @@ -75,6 +75,7 @@ function SkatteverketPersonalConnectionCard() { const SCOPE_LABELS: Record = { 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() { ))}
- {!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') && (

{t('missing_skattekonto')}

@@ -381,11 +384,10 @@ function SkatteverketPersonalConnectionCard() { )}
- {/* 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')) && (