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' && (
Period
@@ -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).
+
+
+ Till dispositionssteget
+
+
+
+ )}
+
{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')) && (
{
vi.mocked(validateYearEndReadiness).mockResolvedValue({
ready: false,
errors: [
- '3 draft journal entries must be posted or deleted before closing',
- 'Unexplained voucher gap in series A: 5-7',
- 'Trial balance is not balanced: debit=100, credit=200',
+ // Current Swedish wording from validateYearEndReadiness…
+ '3 utkast måste bokföras eller raderas innan bokslut',
+ 'Oförklarat verifikationsnummerglapp i serie A: 5-7',
+ 'Råbalansen balanserar inte: debet=100, kredit=200',
+ // …and one legacy English string to prove the fallback still maps.
'Sequence counter integrity error in series A: counter=3 but max voucher=5',
],
- warnings: ['No posted journal entries in this period'],
+ warnings: ['Inga bokförda verifikationer i perioden'],
draftCount: 3,
voucherGaps: [{ series: 'A', gap_start: 5, gap_end: 7 }],
unexplainedGaps: [{ series: 'A', gap_start: 5, gap_end: 7 }],
diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts
index b24dbd59..1aa491cc 100644
--- a/extensions/general/mcp-server/server.ts
+++ b/extensions/general/mcp-server/server.ts
@@ -10969,17 +10969,20 @@ export const tools: McpTool[] = [
// Reshape error strings into structured blockers so the agent (and any
// dashboard) can render and act on each one independently. The lib
// returns flat strings; we tag each with a `kind` heuristic for routing.
+ // validateYearEndReadiness emits Swedish messages (the bokslut wizard
+ // renders them verbatim); English alternates are kept as fallback so
+ // classification never regresses if an older message slips through.
const blockers = validation.errors.map((message) => {
let kind: string = 'other'
- if (/draft journal entries/i.test(message)) kind = 'draft_entries'
- else if (/voucher gap/i.test(message)) kind = 'unexplained_voucher_gap'
- else if (/Sequence counter integrity/i.test(message)) kind = 'sequence_mismatch'
- else if (/Trial balance is not balanced/i.test(message)) kind = 'trial_balance_unbalanced'
- else if (/already closed/i.test(message)) kind = 'period_already_closed'
- else if (/has not yet ended/i.test(message)) kind = 'period_not_ended'
- else if (/closing entry already exists/i.test(message)) kind = 'closing_entry_exists'
- else if (/continuity check failed/i.test(message)) kind = 'opening_balance_continuity'
- else if (/Fiscal period not found/i.test(message)) kind = 'period_not_found'
+ if (/draft journal entries|utkast måste bokföras/i.test(message)) kind = 'draft_entries'
+ else if (/voucher gap|verifikationsnummerglapp/i.test(message)) kind = 'unexplained_voucher_gap'
+ else if (/Sequence counter integrity|Nummerserien i serie/i.test(message)) kind = 'sequence_mismatch'
+ else if (/Trial balance is not balanced|Råbalansen balanserar inte/i.test(message)) kind = 'trial_balance_unbalanced'
+ else if (/already closed|redan stängd/i.test(message)) kind = 'period_already_closed'
+ else if (/has not yet ended|slutdatumet har inte passerat/i.test(message)) kind = 'period_not_ended'
+ else if (/closing entry already exists|Bokslutsverifikation finns redan/i.test(message)) kind = 'closing_entry_exists'
+ else if (/continuity check failed|IB\/UB-kontinuiteten/i.test(message)) kind = 'opening_balance_continuity'
+ else if (/Fiscal period not found|Räkenskapsperioden hittades inte/i.test(message)) kind = 'period_not_found'
return { kind, severity: 'high' as const, message }
})
diff --git a/extensions/general/skatteverket/lib/oauth.ts b/extensions/general/skatteverket/lib/oauth.ts
index 269235ea..dc53cebe 100644
--- a/extensions/general/skatteverket/lib/oauth.ts
+++ b/extensions/general/skatteverket/lib/oauth.ts
@@ -22,8 +22,23 @@ const DEFAULT_OAUTH_BASE_URL = 'https://peroauth2.test.skatteverket.se/oauth2/v1
// description PDF, Tjänstebeskrivning Arbetsgivardeklaration inlämning v1.7,
// section 4.1.2.2: the 403 "Felaktigt access scope" example shows
// `"description": "The required scope agd has been requested for that access token."`
-// The other tokens match the path segments of their respective APIs.
-const DEFAULT_SCOPES = 'momsdeklaration inkforetag skahmst skattekonto agd'
+// The other tokens match the path segments of their respective APIs,
+// EXCEPT skattekonto. The scope names there, learned the hard way:
+// - `ska` = the interactive skattekonto REST API (saldo +
+// transaktioner). Requested since the extension's first
+// commit; removed 2026-05-10 by a "remove unused scopes"
+// cleanup (#431 series), which instantly broke skattekonto
+// sync for every token issued after that hour: the API
+// answers 403 "The required scopes are not authorized"
+// without it. Re-added 2026-07-20. Do not "clean up" again.
+// - `skahmst` = a DIFFERENT bulk service (Skattekonto Hämta huvudmäns
+// saldo och transaktioner, file via E-transport for
+// juridiska läsombud; see dev_docs/skatteverket/skahmst).
+// Not what the sync uses, but harmless to request.
+// - `skattekonto` is NOT a real SKV scope name: SKV silently drops it
+// from every grant. Kept only so a future SKV rename in
+// our favor costs nothing.
+const DEFAULT_SCOPES = 'momsdeklaration inkforetag skahmst skattekonto ska agd'
function getOAuthBaseUrl(): string {
return process.env.SKATTEVERKET_OAUTH_BASE_URL || DEFAULT_OAUTH_BASE_URL
diff --git a/lib/bokslut/__tests__/readiness-aggregator.test.ts b/lib/bokslut/__tests__/readiness-aggregator.test.ts
index a7c075a6..6aa61650 100644
--- a/lib/bokslut/__tests__/readiness-aggregator.test.ts
+++ b/lib/bokslut/__tests__/readiness-aggregator.test.ts
@@ -130,7 +130,7 @@ describe('buildBokslutReadinessReport', () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue(
baseValidation({
ready: false,
- errors: ['3 draft journal entries must be posted or deleted before closing'],
+ errors: ['3 utkast måste bokföras eller raderas innan bokslut'],
draftCount: 3,
}),
)
diff --git a/lib/core/bookkeeping/__tests__/result-appropriation.test.ts b/lib/core/bookkeeping/__tests__/result-appropriation.test.ts
index 99047b7c..dd5218b7 100644
--- a/lib/core/bookkeeping/__tests__/result-appropriation.test.ts
+++ b/lib/core/bookkeeping/__tests__/result-appropriation.test.ts
@@ -127,6 +127,32 @@ describe('generateResultAppropriation', () => {
expect(getOpeningBalances).not.toHaveBeenCalled()
})
+ it('idempotency filter is posted-only: a reversed omföring must not block re-planning', async () => {
+ // After an administrative year-end undo, the period's omföring is
+ // status='reversed' (storno-cancelled, net zero on 2099). The re-run has
+ // to be able to post a fresh one, so the existence query must filter on
+ // status='posted' and NOT use an .in(['posted','reversed']) filter.
+ results = [AB, NO_EXISTING, PERIOD]
+ mockOpeningBalance([{ account_number: '2099', debit: 0, credit: 470621.21 }])
+
+ const builders: Array>> = []
+ const client = {
+ from: vi.fn().mockImplementation(() => {
+ const b = makeBuilder() as Record>
+ builders.push(b)
+ return b
+ }),
+ }
+
+ const entry = await generateResultAppropriation(client as never, 'c1', 'u1', 'p1')
+
+ expect(entry).toEqual(FAKE_ENTRY)
+ // Builder 1 is the journal_entries existence query (builder 0 = settings).
+ const existenceQuery = builders[1]
+ expect(existenceQuery.eq).toHaveBeenCalledWith('status', 'posted')
+ expect(existenceQuery.in).not.toHaveBeenCalled()
+ })
+
it('returns null when 2099 carries no IB balance', async () => {
results = [AB, NO_EXISTING, PERIOD]
mockOpeningBalance([{ account_number: '1930', debit: 5000, credit: 0 }])
diff --git a/lib/core/bookkeeping/__tests__/year-end-service.test.ts b/lib/core/bookkeeping/__tests__/year-end-service.test.ts
index 2bff8231..98abb3c7 100644
--- a/lib/core/bookkeeping/__tests__/year-end-service.test.ts
+++ b/lib/core/bookkeeping/__tests__/year-end-service.test.ts
@@ -110,7 +110,7 @@ describe('validateYearEndReadiness', () => {
const supabase = makeClient()
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.ready).toBe(false)
- expect(result.errors.some((e: string) => e.includes('draft'))).toBe(true)
+ expect(result.errors.some((e: string) => e.includes('utkast'))).toBe(true)
})
it('returns errors when trial balance is unbalanced', async () => {
@@ -128,7 +128,7 @@ describe('validateYearEndReadiness', () => {
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.ready).toBe(false)
expect(result.trialBalanceBalanced).toBe(false)
- expect(result.errors.some((e: string) => e.includes('Trial balance'))).toBe(true)
+ expect(result.errors.some((e: string) => e.includes('Råbalansen balanserar inte'))).toBe(true)
})
it('returns error when period has not yet ended', async () => {
@@ -150,7 +150,7 @@ describe('validateYearEndReadiness', () => {
const supabase = makeClient()
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.ready).toBe(false)
- expect(result.errors.some((e: string) => e.includes('not yet ended'))).toBe(true)
+ expect(result.errors.some((e: string) => e.includes('slutdatumet har inte passerat'))).toBe(true)
})
it('warns on explained voucher gaps', async () => {
@@ -188,7 +188,7 @@ describe('validateYearEndReadiness', () => {
} as never)
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
- expect(result.warnings.some((w: string) => w.includes('documented'))).toBe(true)
+ expect(result.warnings.some((w: string) => w.includes('dokumenterat'))).toBe(true)
expect(result.voucherGaps).toHaveLength(1)
expect(result.voucherGaps[0].series).toBe('A')
expect(result.unexplainedGaps).toHaveLength(0)
@@ -231,7 +231,7 @@ describe('validateYearEndReadiness', () => {
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.ready).toBe(false)
- expect(result.errors.some((e: string) => e.includes('Unexplained voucher gap'))).toBe(true)
+ expect(result.errors.some((e: string) => e.includes('Oförklarat verifikationsnummerglapp'))).toBe(true)
expect(result.unexplainedGaps).toHaveLength(1)
expect(result.unexplainedGaps[0]).toEqual({ gap_start: 5, gap_end: 7, series: 'A' })
})
@@ -281,8 +281,8 @@ describe('validateYearEndReadiness', () => {
expect(result.voucherGaps[0]).toEqual({ gap_start: 3, gap_end: 3, series: 'A' })
expect(result.voucherGaps[1]).toEqual({ gap_start: 1, gap_end: 2, series: 'B' })
expect(result.unexplainedGaps).toHaveLength(2)
- expect(result.errors.some((e: string) => e.includes('series A'))).toBe(true)
- expect(result.errors.some((e: string) => e.includes('series B'))).toBe(true)
+ expect(result.errors.some((e: string) => e.includes('serie A'))).toBe(true)
+ expect(result.errors.some((e: string) => e.includes('serie B'))).toBe(true)
})
it('detects sequence counter mismatch (counter < actual)', async () => {
@@ -312,7 +312,7 @@ describe('validateYearEndReadiness', () => {
const supabase = makeClient()
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.ready).toBe(false)
- expect(result.errors.some((e: string) => e.includes('Sequence counter integrity error'))).toBe(true)
+ expect(result.errors.some((e: string) => e.includes('Nummerserien i serie'))).toBe(true)
expect(result.sequenceMismatches).toHaveLength(1)
expect(result.sequenceMismatches[0]).toEqual({ series: 'A', sequenceCounter: 5, actualMax: 10 })
})
@@ -344,7 +344,7 @@ describe('validateYearEndReadiness', () => {
const supabase = makeClient()
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.ready).toBe(true) // warning, not blocking
- expect(result.warnings.some((w: string) => w.includes('Sequence counter ahead'))).toBe(true)
+ expect(result.warnings.some((w: string) => w.includes('Nummerräknaren ligger före'))).toBe(true)
expect(result.sequenceMismatches).toHaveLength(1)
})
@@ -371,7 +371,7 @@ describe('validateYearEndReadiness', () => {
// Period name intentionally not interpolated into the warning: see
// year-end-service for rationale. We assert on the stable English
// substring instead.
- expect(result.warnings.some((w: string) => w.includes('Next fiscal period already exists'))).toBe(true)
+ expect(result.warnings.some((w: string) => w.includes('Nästa räkenskapsperiod finns redan'))).toBe(true)
})
it('blocks when next period already has opening balances posted', async () => {
@@ -394,7 +394,7 @@ describe('validateYearEndReadiness', () => {
const supabase = makeClient()
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.ready).toBe(false)
- expect(result.errors.some((e: string) => e.includes('already has opening balances'))).toBe(true)
+ expect(result.errors.some((e: string) => e.includes('redan ingående balanser bokförda'))).toBe(true)
})
})
@@ -510,4 +510,90 @@ describe('previewYearEndClosing', () => {
expect(preview.closingAccount).toBe('2010')
expect(preview.closingAccountName).toBe('Eget kapital')
})
+
+ it('flags bolagsskattMissing for AB profit year without any 89xx tax account', async () => {
+ results = [
+ { data: { entity_type: 'aktiebolag' }, error: null },
+ { data: { period_end: '2024-12-31' }, error: null },
+ ]
+ vi.mocked(generateTrialBalance).mockResolvedValue({
+ rows: [
+ { account_number: '3001', account_name: 'Tjänsteintäkter', account_class: 3, closing_debit: 0, closing_credit: 500000 },
+ { account_number: '5010', account_name: 'Lokalhyra', account_class: 5, closing_debit: 200000, closing_credit: 0 },
+ { account_number: '8811', account_name: 'Avsättning till periodiseringsfond', account_class: 8, closing_debit: 75000, closing_credit: 0 },
+ ],
+ isBalanced: true,
+ totalDebit: 275000,
+ totalCredit: 500000,
+ } as never)
+
+ const supabase = makeClient()
+ const preview = await previewYearEndClosing(supabase as never, 'company-1', 'user-1', 'fp-1')
+
+ // 8811 is a disposition, not a tax account: the warning must still fire.
+ expect(preview.netResult).toBe(225000)
+ expect(preview.bolagsskattMissing).toBe(true)
+ })
+
+ it('does not flag bolagsskattMissing when 8910 is booked', async () => {
+ results = [
+ { data: { entity_type: 'aktiebolag' }, error: null },
+ { data: { period_end: '2024-12-31' }, error: null },
+ ]
+ vi.mocked(generateTrialBalance).mockResolvedValue({
+ rows: [
+ { account_number: '3001', account_name: 'Tjänsteintäkter', account_class: 3, closing_debit: 0, closing_credit: 500000 },
+ { account_number: '8910', account_name: 'Skatt på årets resultat', account_class: 8, closing_debit: 103000, closing_credit: 0 },
+ ],
+ isBalanced: true,
+ totalDebit: 103000,
+ totalCredit: 500000,
+ } as never)
+
+ const supabase = makeClient()
+ const preview = await previewYearEndClosing(supabase as never, 'company-1', 'user-1', 'fp-1')
+
+ expect(preview.bolagsskattMissing).toBe(false)
+ })
+
+ it('does not flag bolagsskattMissing for a loss year or for EF', async () => {
+ // Loss year, AB
+ results = [
+ { data: { entity_type: 'aktiebolag' }, error: null },
+ { data: { period_end: '2024-12-31' }, error: null },
+ ]
+ vi.mocked(generateTrialBalance).mockResolvedValue({
+ rows: [
+ { account_number: '3001', account_name: 'Tjänsteintäkter', account_class: 3, closing_debit: 0, closing_credit: 100000 },
+ { account_number: '5010', account_name: 'Lokalhyra', account_class: 5, closing_debit: 150000, closing_credit: 0 },
+ ],
+ isBalanced: true,
+ totalDebit: 150000,
+ totalCredit: 100000,
+ } as never)
+
+ const supabase = makeClient()
+ const lossPreview = await previewYearEndClosing(supabase as never, 'company-1', 'user-1', 'fp-1')
+ expect(lossPreview.netResult).toBe(-50000)
+ expect(lossPreview.bolagsskattMissing).toBe(false)
+
+ // Profit year, EF (tax is never booked for enskild firma)
+ resultIdx = 0
+ results = [
+ { data: { entity_type: 'enskild_firma' }, error: null },
+ { data: { period_end: '2024-12-31' }, error: null },
+ ]
+ vi.mocked(generateTrialBalance).mockResolvedValue({
+ rows: [
+ { account_number: '3001', account_name: 'Intäkter', account_class: 3, closing_debit: 0, closing_credit: 100000 },
+ ],
+ isBalanced: true,
+ totalDebit: 0,
+ totalCredit: 100000,
+ } as never)
+
+ const efPreview = await previewYearEndClosing(supabase as never, 'company-1', 'user-1', 'fp-1')
+ expect(efPreview.netResult).toBe(100000)
+ expect(efPreview.bolagsskattMissing).toBe(false)
+ })
})
diff --git a/lib/core/bookkeeping/result-appropriation-service.ts b/lib/core/bookkeeping/result-appropriation-service.ts
index cf5d9af1..7c147f46 100644
--- a/lib/core/bookkeeping/result-appropriation-service.ts
+++ b/lib/core/bookkeeping/result-appropriation-service.ts
@@ -33,7 +33,10 @@ export interface ResultAppropriationPlan {
*
* Returns null when:
* - the company is not an aktiebolag (enskild firma books to 2010, no 2099),
- * - the period already has a result_appropriation entry (idempotency), or
+ * - the period already has a POSTED result_appropriation entry (idempotency;
+ * a reversed one has been stornoed, no longer moves any balance, and must
+ * not block re-planning: the year-end undo flow reverses the omföring and
+ * the subsequent re-run has to be able to post a fresh one), or
* - 2099 carries no balance (within ORE_TOLERANCE).
*
* Shared by generateResultAppropriation (which posts the plan) and the
@@ -55,14 +58,17 @@ export async function planResultAppropriation(
const entityType = settings?.entity_type ?? 'aktiebolag'
if (entityType !== 'aktiebolag') return null
- // Idempotency: never plan a second omföring for a period that already has one.
+ // Idempotency: never plan a second omföring for a period that already has a
+ // LIVE one. Deliberately posted-only: a reversed omföring is storno-cancelled
+ // (net zero effect on 2099), so it must not block the re-run after an
+ // administrative year-end undo (scripts/undo-year-end-closing.ts).
const { data: existing } = await supabase
.from('journal_entries')
.select('id')
.eq('company_id', companyId)
.eq('fiscal_period_id', periodId)
.eq('source_type', 'result_appropriation')
- .in('status', ['posted', 'reversed'])
+ .eq('status', 'posted')
.limit(1)
.maybeSingle()
if (existing) return null
diff --git a/lib/core/bookkeeping/year-end-service.ts b/lib/core/bookkeeping/year-end-service.ts
index d4b64ca2..45692ea2 100644
--- a/lib/core/bookkeeping/year-end-service.ts
+++ b/lib/core/bookkeeping/year-end-service.ts
@@ -45,10 +45,14 @@ export async function validateYearEndReadiness(
.eq('company_id', companyId)
.single()
+ // The error/warning strings below are Swedish: they render verbatim in the
+ // bokslut wizard (a "stays Swedish" surface per .claude/rules/i18n.md).
+ // The MCP year_end_readiness tool classifies them by regex; keep
+ // extensions/general/mcp-server/server.ts in sync when changing wording.
if (fetchError || !period) {
return {
ready: false,
- errors: ['Fiscal period not found'],
+ errors: ['Räkenskapsperioden hittades inte'],
warnings: [],
draftCount: 0,
voucherGaps: [],
@@ -61,17 +65,17 @@ export async function validateYearEndReadiness(
// Check: period must have ended (BFNAR 2017:3 / ÅRL 2:1)
const today = new Date().toISOString().split('T')[0]
if (period.period_end > today) {
- errors.push('Cannot close a fiscal period that has not yet ended')
+ errors.push('Perioden kan inte stängas: slutdatumet har inte passerat ännu')
}
// Check: period not already closed
if (period.is_closed) {
- errors.push('Period is already closed')
+ errors.push('Perioden är redan stängd')
}
// Check: closing entry doesn't already exist
if (period.closing_entry_id) {
- errors.push('Year-end closing entry already exists for this period')
+ errors.push('Bokslutsverifikation finns redan för perioden')
}
// Check: no draft entries
@@ -84,11 +88,11 @@ export async function validateYearEndReadiness(
const drafts = draftCount ?? 0
if (drafts > 0) {
- errors.push(`${drafts} draft journal entries must be posted or deleted before closing`)
+ errors.push(`${drafts} utkast måste bokföras eller raderas innan bokslut`)
}
// Check: voucher continuity across all series
- let voucherGaps: VoucherGap[] = []
+ const voucherGaps: VoucherGap[] = []
const { data: seriesRows } = await supabase
.from('voucher_sequences')
.select('voucher_series')
@@ -116,7 +120,7 @@ export async function validateYearEndReadiness(
}
// Check gap explanations: unexplained gaps block year-end (BFNAR 2013:2 punkt 5.8)
- let unexplainedGaps: VoucherGap[] = []
+ const unexplainedGaps: VoucherGap[] = []
if (voucherGaps.length > 0) {
const { data: explanations } = await supabase
.from('voucher_gap_explanations')
@@ -135,12 +139,12 @@ export async function validateYearEndReadiness(
const key = `${gap.series}:${gap.gap_start}:${gap.gap_end}`
if (explanationSet.has(key)) {
warnings.push(
- `Voucher gap in series ${gap.series} (${gap.gap_start}-${gap.gap_end}): documented`
+ `Verifikationsnummerglapp i serie ${gap.series} (${gap.gap_start}-${gap.gap_end}): dokumenterat`
)
} else {
unexplainedGaps.push(gap)
errors.push(
- `Unexplained voucher gap in series ${gap.series}: ${gap.gap_start}-${gap.gap_end}`
+ `Oförklarat verifikationsnummerglapp i serie ${gap.series}: ${gap.gap_start}-${gap.gap_end}`
)
}
}
@@ -181,11 +185,11 @@ export async function validateYearEndReadiness(
if (sequenceCounter < actualMax) {
errors.push(
- `Sequence counter integrity error in series ${row.voucher_series}: counter=${sequenceCounter} but max voucher=${actualMax}`
+ `Nummerserien i serie ${row.voucher_series} stämmer inte: räknaren står på ${sequenceCounter} men högsta verifikationsnummer är ${actualMax}`
)
} else {
warnings.push(
- `Sequence counter ahead of actual entries in series ${row.voucher_series}: counter=${sequenceCounter}, max voucher=${actualMax}`
+ `Nummerräknaren ligger före bokförda verifikationer i serie ${row.voucher_series}: räknare=${sequenceCounter}, högsta verifikationsnummer=${actualMax}`
)
}
}
@@ -198,7 +202,7 @@ export async function validateYearEndReadiness(
if (!trialBalanceBalanced) {
errors.push(
- `Trial balance is not balanced: debit=${trialBalance.totalDebit}, credit=${trialBalance.totalCredit}`
+ `Råbalansen balanserar inte: debet=${trialBalance.totalDebit}, kredit=${trialBalance.totalCredit}`
)
}
@@ -211,7 +215,7 @@ export async function validateYearEndReadiness(
.eq('status', 'posted')
if ((entryCount ?? 0) === 0) {
- warnings.push('No posted journal entries in this period')
+ warnings.push('Inga bokförda verifikationer i perioden')
}
// Check: foreign currency items exist but haven't been revalued
@@ -243,14 +247,14 @@ export async function validateYearEndReadiness(
if (((fxReceivables ?? 0) + (fxPayables ?? 0)) > 0) {
warnings.push(
- 'Open foreign currency items exist but have not been revalued (ÅRL 4:13)'
+ 'Öppna poster i utländsk valuta har inte omvärderats (ÅRL 4:13)'
)
}
}
// Check: continuity_verified flag from prior year-end
if (period.continuity_verified === false) {
- errors.push('Opening balance continuity check failed for this period: resolve discrepancies before closing')
+ errors.push('IB/UB-kontinuiteten stämmer inte för perioden: åtgärda avvikelserna innan bokslut')
}
// Check: next period state. A pre-existing next period (from SIE import,
@@ -266,9 +270,9 @@ export async function validateYearEndReadiness(
const nextPeriod = await findNextPeriod(supabase, companyId, fiscalPeriodId)
if (nextPeriod) {
if (nextPeriod.opening_balance_entry_id) {
- errors.push('Next fiscal period already has opening balances posted')
+ errors.push('Nästa räkenskapsperiod har redan ingående balanser bokförda')
} else {
- warnings.push('Next fiscal period already exists: opening balances will be booked into it')
+ warnings.push('Nästa räkenskapsperiod finns redan: ingående balanser bokförs i den')
}
}
@@ -408,6 +412,22 @@ export async function previewYearEndClosing(
}
}
+ // Advisory check: an AB closing a profit year should normally have booked
+ // bolagsskatt (Dr 8910 / Cr 2512) in the dispositions step. If no 89xx tax
+ // account is among the accounts being closed, the profit is untaxed. This
+ // is a warning, not a blocker: zero tax is legitimate when underskotts-
+ // avdrag zeroes the taxable result. 8999 is excluded: it is the manual
+ // result-closing account, not a tax account.
+ // Scanning resultAccountSummary is equivalent to a full 89xx trial-balance
+ // scan: it is built from every class 3-8 account with a non-zero closing
+ // balance, regardless of voucher series, so a booked tax entry cannot be
+ // missed by this check.
+ const hasTaxAccount = resultAccountSummary.some(
+ (a) => a.account_number.startsWith('89') && a.account_number !== '8999'
+ )
+ const bolagsskattMissing =
+ closingAccount === '2099' && netResult > ORE_TOLERANCE && !hasTaxAccount
+
return {
netResult,
closingAccount,
@@ -415,6 +435,7 @@ export async function previewYearEndClosing(
closingLines,
resultAccountSummary,
currencyRevaluation,
+ bolagsskattMissing,
}
}
@@ -442,7 +463,7 @@ export async function executeYearEndClosing(
// 1. Validate readiness
const validation = await validateYearEndReadiness(supabase, companyId, userId, fiscalPeriodId)
if (!validation.ready) {
- throw new Error(`Year-end closing not ready: ${validation.errors.join('; ')}`)
+ throw new Error(`Bokslutet kan inte verkställas: ${validation.errors.join('; ')}`)
}
// Fetch the period for dates
diff --git a/messages/en.json b/messages/en.json
index dadd6276..163d25af 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -2003,7 +2003,8 @@
"connect_waiting": "Waiting for BankID…",
"scope_momsdeklaration": "VAT declaration",
"scope_inkforetag": "Company information",
- "scope_skahmst": "Tax account: balance & transactions",
+ "scope_ska": "Tax account: balance & transactions",
+ "scope_skahmst": "Tax account: file export via E-transport",
"scope_skattekonto": "Tax account",
"scope_agd": "Employer declaration",
"disconnect_failed": "Disconnect failed",
@@ -2012,7 +2013,7 @@
"loading_status": "Loading status…",
"disabled_message": "The Skatteverket integration is temporarily disabled. Contact support.",
"connect_intro": "Connect to Skatteverket with BankID to submit VAT declarations, employer declarations and fetch the tax account balance. When you connect, we immediately fetch the company's tax account balance and transactions and check for pending employer declaration receipts (kvittenser) at Skatteverket.",
- "skahmst_note": "On Skatteverket's consent page one of the permissions appears as skahmst (Rubrik saknas): that's the scope name for tax account balance and transactions (Skattekonto HuvudMan STatus). Skatteverket has not yet published a Swedish description. It's safe to approve.",
+ "skahmst_note": "On Skatteverket's consent page some permissions may appear as short names without a description, e.g. ska and skahmst (Rubrik saknas). They are the tax account scope names: ska is required to fetch balance and transactions. Approve them.",
"connect_approve_all": "Approve all permissions on Skatteverket's consent page when you connect. If a permission is left unchecked we cannot, for example, fetch the tax account.",
"connect_with_bankid": "Connect with BankID",
"expired": "Expired",
diff --git a/messages/sv.json b/messages/sv.json
index ef04fff7..d42f668a 100644
--- a/messages/sv.json
+++ b/messages/sv.json
@@ -2003,7 +2003,8 @@
"connect_waiting": "Väntar på BankID…",
"scope_momsdeklaration": "Momsdeklaration",
"scope_inkforetag": "Företagsinformation",
- "scope_skahmst": "Skattekonto: saldo & transaktioner",
+ "scope_ska": "Skattekonto: saldo & transaktioner",
+ "scope_skahmst": "Skattekonto: filuttag via E-transport",
"scope_skattekonto": "Skattekonto",
"scope_agd": "Arbetsgivardeklaration",
"disconnect_failed": "Frånkoppling misslyckades",
@@ -2012,7 +2013,7 @@
"loading_status": "Hämtar status…",
"disabled_message": "Skatteverket-integrationen är tillfälligt avstängd. Kontakta support.",
"connect_intro": "Anslut till Skatteverket med BankID för att skicka momsdeklaration, arbetsgivardeklaration och hämta saldot på skattekontot. När du ansluter hämtar vi direkt företagets skattekontosaldo och transaktioner och kontrollerar om det finns väntande kvittenser för arbetsgivardeklarationer hos Skatteverket.",
- "skahmst_note": "På Skatteverkets samtyckessida visas en av behörigheterna som skahmst (Rubrik saknas): det är scope-namnet för skattekontots saldo och transaktioner (Skattekonto HuvudMan STatus). Skatteverket har inte publicerat en svensk beskrivning för den ännu. Det är ofarligt att godkänna.",
+ "skahmst_note": "På Skatteverkets samtyckessida kan behörigheter visas med kortnamn utan beskrivning, till exempel ska och skahmst (Rubrik saknas). Det är scope-namnen för skattekontot: ska krävs för att hämta saldo och transaktioner. Godkänn dem.",
"connect_approve_all": "Godkänn alla behörigheter på Skatteverkets samtyckessida när du ansluter. Lämnas en behörighet obockad kan vi till exempel inte hämta skattekontot.",
"connect_with_bankid": "Anslut med BankID",
"expired": "Utgången",
diff --git a/scripts/undo-year-end-closing.ts b/scripts/undo-year-end-closing.ts
new file mode 100644
index 00000000..e57895d0
--- /dev/null
+++ b/scripts/undo-year-end-closing.ts
@@ -0,0 +1,444 @@
+#!/usr/bin/env npx tsx
+/**
+ * Administrative undo of an executed year-end closing (bokslut).
+ *
+ * Restores a company to the state just before "Verkställ bokslut" so the
+ * dispositions/preview step can be re-run, WITHOUT violating verifikat
+ * immutability: every journal change is a storno posted through the
+ * bookkeeping engine (BFL 5 kap 5 §). Nothing is edited or deleted.
+ *
+ * What it does, in order:
+ * 1. Preconditions: the period has a closing entry; no årsredovisning
+ * submission or signature request exists for the company; the next
+ * period (if any) is open and has no closing entry of its own. Other
+ * posted entries in the next period are reported but do not block
+ * (their balances are independent of the IB; the re-run's continuity
+ * check revalidates everything).
+ * 2. Reverse the next period's result_appropriation entry (2099 -> 2098).
+ * 3. Reverse the next period's opening_balance entry. reverseEntry()
+ * itself clears opening_balance_entry_id + opening_balances_set on the
+ * period (two-step, per enforce_opening_balance_immutability).
+ * 4. Reset the next period's continuity_verified to NULL.
+ * 5. Reopen the closed period (is_closed=false, closed_at=null,
+ * locked_at=null).
+ * 6. Reverse the closing entry (storno in the reopened period).
+ * 7. Clear closing_entry_id. This must come AFTER the storno: the
+ * enforce_opening_balance_immutability trigger only allows detaching a
+ * closing entry that is reversed with a posted storno chain
+ * (migration 20260720140000).
+ * 8. Write an explicit audit_log row (BFNAR 2013:2 kap. 8: reopening is a
+ * sensitive control change) and verify the final state.
+ *
+ * RESUMABLE: every step is idempotent-or-skipped, so if a run dies midway
+ * (e.g. after the reopen but before the detach) simply re-run with the same
+ * arguments: already-reversed entries are skipped, the reopen is skipped
+ * when the period is already open, and the run continues from the first
+ * incomplete step.
+ *
+ * Dispositions already booked in the period (periodiseringsfond, SLP,
+ * överavskrivningar) are NOT touched: only the closing entry and the two
+ * auto-generated new-year entries are reversed.
+ *
+ * Attribution (BFL 5 kap 6 §): pass --user-id to attribute the stornos
+ * explicitly (normally the company owner who requested the reset); defaults
+ * to the company owner, with a loud warning on the arbitrary-member fallback.
+ *
+ * Usage:
+ * # Dry run (read-only) against the env in .env.local
+ * npx tsx scripts/undo-year-end-closing.ts --company-id --period-id
+ *
+ * # Apply, attributing to a specific user. --confirm-url must restate the
+ * # target Supabase URL so the operator confirms WHICH environment mutates.
+ * npx tsx scripts/undo-year-end-closing.ts --company-id --period-id \
+ * --user-id --commit --confirm-url https://.supabase.co
+ *
+ * # Against another environment (e.g. production)
+ * npx tsx scripts/undo-year-end-closing.ts --env-file .env.prod.local ...
+ *
+ * Run against staging first; only run against prod after reviewing the dry-run.
+ */
+
+import { config } from 'dotenv'
+
+function arg(name: string): string | undefined {
+ const i = process.argv.indexOf(`--${name}`)
+ return i >= 0 ? process.argv[i + 1] : undefined
+}
+
+config({ path: arg('env-file') ?? '.env.local' })
+
+import { createClient, type SupabaseClient } from '@supabase/supabase-js'
+import { reverseEntry } from '../lib/bookkeeping/engine'
+
+const COMPANY_ID = arg('company-id')
+const PERIOD_ID = arg('period-id')
+const USER_ID_ARG = arg('user-id')
+const COMMIT = process.argv.includes('--commit')
+const CONFIRM_URL = arg('confirm-url')
+
+const url = process.env.NEXT_PUBLIC_SUPABASE_URL
+const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
+
+if (!url || !serviceKey) {
+ console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY')
+ process.exit(1)
+}
+if (!serviceKey.startsWith('eyJ') && !serviceKey.startsWith('sb_secret_')) {
+ console.error(
+ 'SUPABASE_SERVICE_ROLE_KEY does not look like a service-role key (expected a JWT or sb_secret_ prefix); check the env file'
+ )
+ process.exit(1)
+}
+if (!COMPANY_ID || !PERIOD_ID) {
+ console.error(
+ 'Usage: --company-id --period-id [--user-id ] [--env-file ] [--commit --confirm-url ]'
+ )
+ process.exit(1)
+}
+// A prefix check on the key cannot catch a right-looking key from the WRONG
+// environment (e.g. a staging env file against prod). For mutations, the
+// operator must restate the target URL so an accidental env swap fails loud.
+if (COMMIT && CONFIRM_URL !== url) {
+ console.error(
+ `--commit requires --confirm-url to exactly match the target Supabase URL.\n` +
+ ` target: ${url}\n` +
+ ` confirm-url: ${CONFIRM_URL ?? '(missing)'}`
+ )
+ process.exit(1)
+}
+
+const supabase: SupabaseClient = createClient(url, serviceKey, {
+ auth: { persistSession: false },
+})
+
+function fail(msg: string): never {
+ console.error(`BLOCKED: ${msg}`)
+ process.exit(1)
+}
+
+type EntryRow = {
+ id: string
+ voucher_series: string | null
+ voucher_number: number | null
+ entry_date: string
+ description: string
+ source_type: string
+ status: string
+}
+
+function label(e: EntryRow): string {
+ return `${e.voucher_series ?? 'A'}${e.voucher_number} ${e.entry_date} "${e.description}" [${e.source_type}/${e.status}]`
+}
+
+async function main() {
+ console.log(`Mode: ${COMMIT ? 'COMMIT' : 'dry-run'} target: ${url}`)
+
+ // ── Load period ────────────────────────────────────────────────
+ const { data: period, error: periodError } = await supabase
+ .from('fiscal_periods')
+ .select('*')
+ .eq('id', PERIOD_ID)
+ .eq('company_id', COMPANY_ID)
+ .single()
+ if (periodError || !period) fail(`fiscal period not found: ${periodError?.message}`)
+
+ console.log(`Period: ${period.name} (${period.period_start} - ${period.period_end})`)
+ if (!period.closing_entry_id) fail('period has no closing_entry_id: nothing to undo')
+ if (!period.is_closed) {
+ console.warn(' note: period is not closed (resuming a partial undo, or year-end failed midway)')
+ }
+
+ // ── Preconditions ──────────────────────────────────────────────
+ const { count: submissions } = await supabase
+ .from('arsredovisning_submissions')
+ .select('id', { count: 'exact', head: true })
+ .eq('company_id', COMPANY_ID)
+ .eq('fiscal_period_id', PERIOD_ID)
+ if ((submissions ?? 0) > 0) fail('an årsredovisning submission exists for this period: refuse to reopen')
+
+ const { count: signatureRequests } = await supabase
+ .from('arsredovisning_signature_requests')
+ .select('id', { count: 'exact', head: true })
+ .eq('company_id', COMPANY_ID)
+ .eq('fiscal_period_id', PERIOD_ID)
+ if ((signatureRequests ?? 0) > 0) fail('an årsredovisning signature request exists for this period: refuse to reopen')
+
+ const { data: settings } = await supabase
+ .from('company_settings')
+ .select('bookkeeping_locked_through')
+ .eq('company_id', COMPANY_ID)
+ .maybeSingle()
+ if (
+ settings?.bookkeeping_locked_through &&
+ settings.bookkeeping_locked_through >= period.period_end
+ ) {
+ fail(
+ `company lock date ${settings.bookkeeping_locked_through} covers the period end: clear it first`
+ )
+ }
+
+ const { data: closingEntry } = await supabase
+ .from('journal_entries')
+ .select('id, voucher_series, voucher_number, entry_date, description, source_type, status')
+ .eq('id', period.closing_entry_id)
+ .eq('company_id', COMPANY_ID)
+ .single()
+ if (!closingEntry) fail('closing entry row not found')
+ if (closingEntry.status !== 'posted' && closingEntry.status !== 'reversed') {
+ fail(`closing entry has unexpected status (${closingEntry.status})`)
+ }
+ console.log(`Closing entry: ${label(closingEntry)}`)
+
+ // ── Next period + its auto-generated entries ───────────────────
+ // Chain lookup first, then date-based fallback (day after period_end),
+ // mirroring findNextPeriod() in period-service: periods created before the
+ // previous_period_id chain was wired up must still be found, or their
+ // IB/appropriation entries would be left posted and block the re-run.
+ let { data: nextPeriod } = await supabase
+ .from('fiscal_periods')
+ .select('*')
+ .eq('company_id', COMPANY_ID)
+ .eq('previous_period_id', PERIOD_ID)
+ .maybeSingle()
+
+ if (!nextPeriod) {
+ const dayAfter = new Date(period.period_end + 'T00:00:00Z')
+ dayAfter.setUTCDate(dayAfter.getUTCDate() + 1)
+ const { data: byDate } = await supabase
+ .from('fiscal_periods')
+ .select('*')
+ .eq('company_id', COMPANY_ID)
+ .eq('period_start', dayAfter.toISOString().slice(0, 10))
+ .maybeSingle()
+ nextPeriod = byDate
+ }
+
+ let ibEntry: EntryRow | null = null
+ let appropriationEntry: EntryRow | null = null
+
+ if (nextPeriod) {
+ console.log(`Next period: ${nextPeriod.name} (${nextPeriod.period_start} - ${nextPeriod.period_end})`)
+ if (nextPeriod.is_closed || nextPeriod.closing_entry_id) {
+ fail('next period is itself closed: undo that year first')
+ }
+ if (nextPeriod.locked_at) fail('next period is locked: unlock it first')
+
+ const { data: nextEntries } = await supabase
+ .from('journal_entries')
+ .select('id, voucher_series, voucher_number, entry_date, description, source_type, status')
+ .eq('company_id', COMPANY_ID)
+ .eq('fiscal_period_id', nextPeriod.id)
+ .neq('status', 'cancelled')
+ .order('voucher_number', { ascending: true })
+
+ for (const e of (nextEntries ?? []) as EntryRow[]) {
+ if (e.status !== 'posted') continue
+ // The IB entry is matched by the period's own link when set; the
+ // source_type scan is the fallback for a partial run where the link
+ // was already cleared but a posted IB somehow remains.
+ if (
+ e.source_type === 'opening_balance' &&
+ (!nextPeriod.opening_balance_entry_id || e.id === nextPeriod.opening_balance_entry_id)
+ ) {
+ ibEntry = e
+ } else if (e.source_type === 'result_appropriation') {
+ appropriationEntry = e
+ } else {
+ // Real bookkeeping already exists in the new year. That is fine for
+ // the reset itself (their balances are independent of the IB), but
+ // say so loudly so the operator has thought about it.
+ console.warn(` note: next period has other posted entries, e.g. ${label(e)}`)
+ }
+ }
+ if (ibEntry) console.log(`Opening balance entry: ${label(ibEntry)}`)
+ if (appropriationEntry) console.log(`Result appropriation entry: ${label(appropriationEntry)}`)
+ } else {
+ console.log('No next period found: only the closing entry will be reversed')
+ }
+
+ // ── Attribution ────────────────────────────────────────────────
+ let userId = USER_ID_ARG
+ if (!userId) {
+ const { data: owner } = await supabase
+ .from('company_members')
+ .select('user_id, role')
+ .eq('company_id', COMPANY_ID)
+ .eq('role', 'owner')
+ .limit(1)
+ .maybeSingle()
+ if (owner) {
+ userId = owner.user_id
+ } else {
+ const { data: anyMember } = await supabase
+ .from('company_members')
+ .select('user_id')
+ .eq('company_id', COMPANY_ID)
+ .limit(1)
+ .maybeSingle()
+ if (!anyMember) fail('no company member to attribute the stornos to')
+ userId = anyMember.user_id
+ console.warn('WARNING: no owner found; attributing to an arbitrary member. Pass --user-id.')
+ }
+ }
+ console.log(`Attribution user: ${userId}`)
+
+ if (!COMMIT) {
+ console.log('\nDry run only. Planned actions:')
+ if (appropriationEntry) console.log(` 1. Storno ${label(appropriationEntry)}`)
+ if (ibEntry) console.log(` 2. Storno ${label(ibEntry)} (clears IB link + flag on next period)`)
+ if (nextPeriod) console.log(' 3. Reset next period continuity_verified to NULL')
+ if (period.is_closed || period.locked_at) {
+ console.log(` 4. Reopen ${period.name}: is_closed=false, closed_at=null, locked_at=null`)
+ }
+ if (closingEntry.status === 'posted') console.log(` 5. Storno ${label(closingEntry)}`)
+ console.log(' 6. Clear closing_entry_id on the reopened period (+ audit_log)')
+ console.log('Re-run with --commit to apply.')
+ return
+ }
+
+ // ── Execute ────────────────────────────────────────────────────
+ if (appropriationEntry) {
+ console.log('Reversing result appropriation entry…')
+ const storno = await reverseEntry(supabase, COMPANY_ID!, userId!, appropriationEntry.id)
+ console.log(` posted storno ${storno.voucher_series}${storno.voucher_number}`)
+ }
+
+ if (ibEntry) {
+ console.log('Reversing opening balance entry…')
+ const storno = await reverseEntry(supabase, COMPANY_ID!, userId!, ibEntry.id)
+ console.log(` posted storno ${storno.voucher_series}${storno.voucher_number}`)
+ }
+
+ if (nextPeriod) {
+ const { error: contError } = await supabase
+ .from('fiscal_periods')
+ .update({ continuity_verified: null })
+ .eq('id', nextPeriod.id)
+ .eq('company_id', COMPANY_ID)
+ if (contError) fail(`failed to reset continuity_verified: ${contError.message}`)
+ }
+
+ if (period.is_closed || period.locked_at) {
+ console.log('Reopening the closed period…')
+ // closing_entry_id is NOT cleared here: the immutability trigger only
+ // allows detaching a closing entry that is already storno-reversed, so
+ // the link is cleared after the storno below.
+ const { error: reopenError } = await supabase
+ .from('fiscal_periods')
+ .update({
+ is_closed: false,
+ closed_at: null,
+ locked_at: null,
+ })
+ .eq('id', PERIOD_ID)
+ .eq('company_id', COMPANY_ID)
+ if (reopenError) fail(`failed to reopen period: ${reopenError.message}`)
+ } else {
+ console.log('Period already open (resume): skipping reopen')
+ }
+
+ let closingStornoLabel = 'already reversed (resume)'
+ if (closingEntry.status === 'posted') {
+ console.log('Reversing closing entry…')
+ const closingStorno = await reverseEntry(supabase, COMPANY_ID!, userId!, closingEntry.id)
+ closingStornoLabel = `${closingStorno.voucher_series}${closingStorno.voucher_number}`
+ console.log(` posted storno ${closingStornoLabel}`)
+ } else {
+ console.log('Closing entry already reversed (resume): skipping storno')
+ }
+
+ console.log('Detaching closing entry from the period…')
+ const { error: detachError } = await supabase
+ .from('fiscal_periods')
+ .update({ closing_entry_id: null })
+ .eq('id', PERIOD_ID)
+ .eq('company_id', COMPANY_ID)
+ if (detachError) fail(`failed to clear closing_entry_id: ${detachError.message}`)
+
+ // Audit trail AFTER the mutations so the row describes what actually
+ // happened (BFNAR 2013:2 kap. 8 behandlingshistorik). The automatic
+ // write_audit_log trigger also recorded each UPDATE individually.
+ const auditRow = {
+ user_id: userId,
+ company_id: COMPANY_ID,
+ action: 'UPDATE',
+ table_name: 'fiscal_periods',
+ record_id: PERIOD_ID,
+ description:
+ `Administrative year-end undo: period reopened (${period.name}, ${period.period_start} to ${period.period_end}); ` +
+ `closing entry ${closingEntry.voucher_series ?? 'A'}${closingEntry.voucher_number} reversed by storno ${closingStornoLabel} ` +
+ 'and detached; auto-generated new-year entries reversed on user request.',
+ old_state: {
+ is_closed: period.is_closed,
+ closed_at: period.closed_at,
+ locked_at: period.locked_at,
+ closing_entry_id: period.closing_entry_id,
+ },
+ new_state: { is_closed: false, closed_at: null, locked_at: null, closing_entry_id: null },
+ }
+ // BFNAR 2013:2 kap. 8: the behandlingshistorik row is part of the undo.
+ // The mutations cannot be rolled back from here (each already committed via
+ // PostgREST), so retry the insert before giving up.
+ let auditError: { message: string } | null = null
+ for (let attempt = 1; attempt <= 3; attempt++) {
+ const { error } = await supabase.from('audit_log').insert(auditRow)
+ auditError = error
+ if (!auditError) break
+ console.warn(` audit_log insert attempt ${attempt}/3 failed: ${auditError.message}`)
+ }
+ if (auditError) {
+ fail(
+ `audit_log insert failed after 3 attempts: ${auditError.message}\n` +
+ 'The period state itself is valid (all mutations completed), but the undo is NOT ' +
+ 'complete until the behandlingshistorik row exists (BFNAR 2013:2 kap. 8). ' +
+ 'Insert the audit_log row manually (see auditRow in this script for the exact ' +
+ 'content) before treating the undo as done.'
+ )
+ }
+
+ // ── Verify ─────────────────────────────────────────────────────
+ const { data: closingAfter } = await supabase
+ .from('journal_entries')
+ .select('status')
+ .eq('id', closingEntry.id)
+ .eq('company_id', COMPANY_ID)
+ .single()
+ const { data: periodAfter } = await supabase
+ .from('fiscal_periods')
+ .select('is_closed, locked_at, closing_entry_id')
+ .eq('id', PERIOD_ID)
+ .eq('company_id', COMPANY_ID)
+ .single()
+ const { data: nextAfter } = nextPeriod
+ ? await supabase
+ .from('fiscal_periods')
+ .select('opening_balance_entry_id, opening_balances_set, continuity_verified')
+ .eq('id', nextPeriod.id)
+ .eq('company_id', COMPANY_ID)
+ .single()
+ : { data: null }
+
+ const ok =
+ closingAfter?.status === 'reversed' &&
+ periodAfter?.is_closed === false &&
+ periodAfter?.locked_at === null &&
+ periodAfter?.closing_entry_id === null &&
+ (!nextPeriod ||
+ (nextAfter?.opening_balance_entry_id === null && nextAfter?.opening_balances_set === false))
+
+ if (!ok) {
+ console.error('VERIFICATION FAILED: inspect state manually', {
+ closingAfter,
+ periodAfter,
+ nextAfter,
+ })
+ process.exit(1)
+ }
+
+ console.log('\nDone. The period is open again; the dispositions/preview step can be re-run.')
+}
+
+main().catch((err) => {
+ console.error('FAILED:', err)
+ process.exit(1)
+})
diff --git a/supabase/migrations/20260720140000_closing_entry_detach_escape_hatch.sql b/supabase/migrations/20260720140000_closing_entry_detach_escape_hatch.sql
new file mode 100644
index 00000000..ebb8940c
--- /dev/null
+++ b/supabase/migrations/20260720140000_closing_entry_detach_escape_hatch.sql
@@ -0,0 +1,73 @@
+-- Allow detaching or replacing a period's closing_entry_id ONLY when the
+-- previously referenced closing entry has been genuinely reversed by storno.
+--
+-- Background: the administrative year-end undo flow (scripts/
+-- undo-year-end-closing.ts) reverses the bokslutsverifikation with a storno
+-- (BFL 5 kap 5 §: never edit, never delete) and must then clear
+-- closing_entry_id so the year-end wizard can be re-run. The previous rule
+-- blocked ANY change to closing_entry_id once set, which made an executed
+-- bokslut unrecoverable even before an arsredovisning exists.
+--
+-- The invariant that matters is preserved and tightened:
+-- * a period can never abandon a LIVE (posted) closing entry;
+-- * the status='reversed' flag alone is not trusted (it is reachable via a
+-- direct PostgREST update by a writer-role member): the actual storno
+-- chain that only the engine's reverseEntry() produces must exist
+-- (a posted source_type='storno' entry with reverses_id pointing at the
+-- old closing entry);
+-- * a non-NULL replacement must be a posted year_end entry in the same
+-- company and period (what executeYearEndClosing sets on a re-run).
+--
+-- The opening-balance clause is unchanged.
+
+CREATE OR REPLACE FUNCTION public.enforce_opening_balance_immutability()
+RETURNS trigger
+LANGUAGE plpgsql
+SET search_path TO 'public'
+AS $function$
+BEGIN
+ -- Only check if opening_balance_entry_id is being changed
+ IF OLD.opening_balance_entry_id IS NOT NULL
+ AND OLD.opening_balances_set = true
+ AND NEW.opening_balance_entry_id IS DISTINCT FROM OLD.opening_balance_entry_id THEN
+ RAISE EXCEPTION 'Cannot modify opening_balance_entry_id on period "%" — opening balances are immutable once set',
+ OLD.name;
+ END IF;
+
+ -- Block changing closing_entry_id once set, UNLESS the referenced closing
+ -- entry has been reversed by a real storno (administrative year-end undo).
+ IF OLD.closing_entry_id IS NOT NULL
+ AND NEW.closing_entry_id IS DISTINCT FROM OLD.closing_entry_id THEN
+
+ IF NOT EXISTS (
+ SELECT 1
+ FROM journal_entries je
+ JOIN journal_entries storno
+ ON storno.reverses_id = je.id
+ AND storno.source_type = 'storno'
+ AND storno.status = 'posted'
+ AND storno.company_id = OLD.company_id
+ WHERE je.id = OLD.closing_entry_id
+ AND je.company_id = OLD.company_id
+ AND je.status = 'reversed'
+ ) THEN
+ RAISE EXCEPTION 'Cannot modify closing_entry_id on period "%": year-end closing is immutable',
+ OLD.name;
+ END IF;
+
+ IF NEW.closing_entry_id IS NOT NULL AND NOT EXISTS (
+ SELECT 1 FROM journal_entries ne
+ WHERE ne.id = NEW.closing_entry_id
+ AND ne.company_id = NEW.company_id
+ AND ne.fiscal_period_id = NEW.id
+ AND ne.source_type = 'year_end'
+ AND ne.status = 'posted'
+ ) THEN
+ RAISE EXCEPTION 'closing_entry_id on period "%" must reference a posted year_end entry in the same period',
+ OLD.name;
+ END IF;
+ END IF;
+
+ RETURN NEW;
+END;
+$function$;
diff --git a/tests/pg/closing-entry-detach.pg.test.ts b/tests/pg/closing-entry-detach.pg.test.ts
new file mode 100644
index 00000000..31d7ec99
--- /dev/null
+++ b/tests/pg/closing-entry-detach.pg.test.ts
@@ -0,0 +1,242 @@
+import { describe, it, expect, beforeAll } from 'vitest'
+import { randomUUID } from 'node:crypto'
+import { getPool } from './setup'
+import { seedCompany, insertDraftJournalEntry } from './fixtures'
+
+// Escape hatch in enforce_opening_balance_immutability (migration
+// 20260720140000): closing_entry_id may only change once set when the
+// previously referenced closing entry is status='reversed' AND a posted
+// storno entry with reverses_id pointing at it exists (the chain only the
+// engine's reverseEntry() produces). A non-NULL replacement must be a posted
+// year_end entry in the same company and period. Used by the administrative
+// year-end undo flow (scripts/undo-year-end-closing.ts).
+
+async function insertStornoOf(params: {
+ userId: string
+ companyId: string
+ fiscalPeriodId: string
+ reversesId: string
+ voucherNumber: number
+ status?: string
+ sourceType?: string
+}): Promise {
+ const id = randomUUID()
+ await getPool().query(
+ `INSERT INTO public.journal_entries
+ (id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
+ entry_date, description, source_type, status, reverses_id)
+ VALUES ($1, $2, $3, $4, $5, 'A', '2026-12-31', 'Makulering', $6, $7, $8)`,
+ [
+ id,
+ params.userId,
+ params.companyId,
+ params.fiscalPeriodId,
+ params.voucherNumber,
+ params.sourceType ?? 'storno',
+ params.status ?? 'posted',
+ params.reversesId,
+ ],
+ )
+ return id
+}
+
+describe('closing_entry_id detach escape hatch', () => {
+ let companyId: string
+ let userId: string
+ let fiscalPeriodId: string
+ let closingEntryId: string
+
+ beforeAll(async () => {
+ const seeded = await seedCompany()
+ companyId = seeded.companyId
+ userId = seeded.userId
+ fiscalPeriodId = seeded.fiscalPeriodId
+
+ closingEntryId = await insertDraftJournalEntry({
+ userId,
+ companyId,
+ fiscalPeriodId,
+ entryDate: '2026-12-31',
+ description: 'Årsbokslut',
+ sourceType: 'year_end',
+ status: 'posted',
+ voucherNumber: 1,
+ })
+
+ await getPool().query(
+ `UPDATE public.fiscal_periods SET closing_entry_id = $1 WHERE id = $2`,
+ [closingEntryId, fiscalPeriodId],
+ )
+ })
+
+ it('blocks detaching a posted (live) closing entry', async () => {
+ await expect(
+ getPool().query(
+ `UPDATE public.fiscal_periods SET closing_entry_id = NULL WHERE id = $1`,
+ [fiscalPeriodId],
+ ),
+ ).rejects.toThrow(/year-end closing is immutable/)
+ })
+
+ it('blocks detaching when status is reversed but no storno chain exists', async () => {
+ await getPool().query(
+ `UPDATE public.journal_entries SET status = 'reversed' WHERE id = $1`,
+ [closingEntryId],
+ )
+
+ await expect(
+ getPool().query(
+ `UPDATE public.fiscal_periods SET closing_entry_id = NULL WHERE id = $1`,
+ [fiscalPeriodId],
+ ),
+ ).rejects.toThrow(/year-end closing is immutable/)
+ })
+
+ it('blocks the escape hatch when the storno is not posted', async () => {
+ // closingEntryId is status='reversed' from the previous test.
+ await insertStornoOf({
+ userId,
+ companyId,
+ fiscalPeriodId,
+ reversesId: closingEntryId,
+ voucherNumber: 2,
+ status: 'cancelled',
+ })
+
+ await expect(
+ getPool().query(
+ `UPDATE public.fiscal_periods SET closing_entry_id = NULL WHERE id = $1`,
+ [fiscalPeriodId],
+ ),
+ ).rejects.toThrow(/year-end closing is immutable/)
+ })
+
+ it('blocks replacing a reversed closing entry with a non-year_end entry', async () => {
+ // Complete the storno chain so the reversal itself is now legitimate.
+ await insertStornoOf({
+ userId,
+ companyId,
+ fiscalPeriodId,
+ reversesId: closingEntryId,
+ voucherNumber: 3,
+ })
+
+ const manualId = await insertDraftJournalEntry({
+ userId,
+ companyId,
+ fiscalPeriodId,
+ entryDate: '2026-12-31',
+ sourceType: 'manual',
+ status: 'posted',
+ voucherNumber: 4,
+ })
+
+ await expect(
+ getPool().query(
+ `UPDATE public.fiscal_periods SET closing_entry_id = $1 WHERE id = $2`,
+ [manualId, fiscalPeriodId],
+ ),
+ ).rejects.toThrow(/must reference a posted year_end entry/)
+ })
+
+ it('allows detaching once the closing entry is reversed with a posted storno', async () => {
+ await getPool().query(
+ `UPDATE public.fiscal_periods SET closing_entry_id = NULL WHERE id = $1`,
+ [fiscalPeriodId],
+ )
+
+ const { rows } = await getPool().query(
+ `SELECT closing_entry_id FROM public.fiscal_periods WHERE id = $1`,
+ [fiscalPeriodId],
+ )
+ expect(rows[0].closing_entry_id).toBeNull()
+ })
+
+ it('still allows setting closing_entry_id from NULL (normal year-end run)', async () => {
+ const newClosingId = await insertDraftJournalEntry({
+ userId,
+ companyId,
+ fiscalPeriodId,
+ entryDate: '2026-12-31',
+ sourceType: 'year_end',
+ status: 'posted',
+ voucherNumber: 5,
+ })
+ await getPool().query(
+ `UPDATE public.fiscal_periods SET closing_entry_id = $1 WHERE id = $2`,
+ [newClosingId, fiscalPeriodId],
+ )
+ const { rows } = await getPool().query(
+ `SELECT closing_entry_id FROM public.fiscal_periods WHERE id = $1`,
+ [fiscalPeriodId],
+ )
+ expect(rows[0].closing_entry_id).toBe(newClosingId)
+ })
+
+ it('allows replacing a properly reversed closing entry with a posted year_end entry', async () => {
+ // Reverse the current closing entry with a full storno chain, then swap
+ // directly to a new posted year_end entry (re-run without detach first).
+ const { rows: current } = await getPool().query(
+ `SELECT closing_entry_id FROM public.fiscal_periods WHERE id = $1`,
+ [fiscalPeriodId],
+ )
+ const currentClosingId = current[0].closing_entry_id
+
+ await getPool().query(
+ `UPDATE public.journal_entries SET status = 'reversed' WHERE id = $1`,
+ [currentClosingId],
+ )
+ await insertStornoOf({
+ userId,
+ companyId,
+ fiscalPeriodId,
+ reversesId: currentClosingId,
+ voucherNumber: 6,
+ })
+
+ const replacementId = await insertDraftJournalEntry({
+ userId,
+ companyId,
+ fiscalPeriodId,
+ entryDate: '2026-12-31',
+ sourceType: 'year_end',
+ status: 'posted',
+ voucherNumber: 7,
+ })
+
+ await getPool().query(
+ `UPDATE public.fiscal_periods SET closing_entry_id = $1 WHERE id = $2`,
+ [replacementId, fiscalPeriodId],
+ )
+ const { rows } = await getPool().query(
+ `SELECT closing_entry_id FROM public.fiscal_periods WHERE id = $1`,
+ [fiscalPeriodId],
+ )
+ expect(rows[0].closing_entry_id).toBe(replacementId)
+ })
+
+ it('opening balance immutability is unchanged', async () => {
+ const ibEntryId = await insertDraftJournalEntry({
+ userId,
+ companyId,
+ fiscalPeriodId,
+ entryDate: '2026-01-01',
+ sourceType: 'opening_balance',
+ status: 'posted',
+ voucherNumber: 8,
+ })
+ await getPool().query(
+ `UPDATE public.fiscal_periods
+ SET opening_balance_entry_id = $1, opening_balances_set = true
+ WHERE id = $2`,
+ [ibEntryId, fiscalPeriodId],
+ )
+
+ await expect(
+ getPool().query(
+ `UPDATE public.fiscal_periods SET opening_balance_entry_id = NULL WHERE id = $1`,
+ [fiscalPeriodId],
+ ),
+ ).rejects.toThrow(/opening balances are immutable/)
+ })
+})
diff --git a/types/index.ts b/types/index.ts
index a94ab14f..72dd6f9e 100644
--- a/types/index.ts
+++ b/types/index.ts
@@ -3062,6 +3062,12 @@ export interface YearEndPreview {
closingLines: CreateJournalEntryLineInput[]
resultAccountSummary: { account_number: string; account_name: string; amount: number }[]
currencyRevaluation: CurrencyRevaluationPreview | null
+ /**
+ * True when an aktiebolag is about to close a profit year with no tax
+ * account (89xx except 8999) among the accounts being closed. Advisory
+ * only, never a blocker: zero tax is legitimate with underskottsavdrag.
+ */
+ bolagsskattMissing: boolean
}
export interface YearEndResult {