diff --git a/components/bookkeeping/year-end/PreflightStep.tsx b/components/bookkeeping/year-end/PreflightStep.tsx
index d8233ba0..ac8a329a 100644
--- a/components/bookkeeping/year-end/PreflightStep.tsx
+++ b/components/bookkeeping/year-end/PreflightStep.tsx
@@ -15,6 +15,12 @@ interface PreflightStepProps {
onContinue: () => void
}
+/** A blocker as rendered: code is null for legacy responses without codes. */
+interface DisplayBlocker {
+ code: string | null
+ message: string
+}
+
/** Sans eyebrow section head with a trailing hairline (house idiom). */
function SectionHead({ icon, children }: { icon?: React.ReactNode; children: React.ReactNode }) {
return (
@@ -48,6 +54,11 @@ export function PreflightStep({ report, isLoading, error, onContinue }: Prefligh
return null
}
+ // A response cached from before blockerItems shipped only has the plain
+ // strings: fall back so blockers never disappear, just without links.
+ const blockerItems: DisplayBlocker[] =
+ report.blockerItems ?? report.blockers.map((message) => ({ code: null, message }))
+
return (
{/* Period line: muted text for the normal state, chip only when the
@@ -66,13 +77,13 @@ export function PreflightStep({ report, isLoading, error, onContinue }: Prefligh
)}
- {report.blockers.length > 0 && (
+ {blockerItems.length > 0 && (
}>
Måste åtgärdas innan bokslut
- {report.blockers.map((blocker, i) => (
-
+ {blockerItems.map((blocker, i) => (
+
))}
)}
@@ -121,30 +132,40 @@ export function PreflightStep({ report, isLoading, error, onContinue }: Prefligh
}
/**
- * Renders a blocker with a contextual action link when we can derive one.
- * Falls back to plain text otherwise.
+ * Renders a blocker with a contextual action link derived from its stable
+ * machine code (YearEndBlockerCode). Codes without an existing remediation
+ * page (voucher gaps, sequence counter, period state) render as plain text:
+ * a link must never point at a page that does not exist. UNBOOKED_CHECK_FAILED
+ * is deliberately link-less too: the remedy is to re-run the check, not to
+ * visit a page.
*/
-function BlockerRow({ blocker, report }: { blocker: string; report: BokslutReadinessReport }) {
+function BlockerRow({ blocker }: { blocker: DisplayBlocker }) {
let href: string | null = null
let actionLabel: string | null = null
- if (/draft journal entries/i.test(blocker) && report.draftCount > 0) {
- href = '/bookkeeping?status=draft'
- actionLabel = 'Visa utkast'
- } else if (/voucher gap/i.test(blocker)) {
- href = '/bookkeeping/voucher-gaps'
- actionLabel = 'Hantera nummerlucka'
- } else if (/trial balance/i.test(blocker)) {
- href = '/reports/trial-balance'
- actionLabel = 'Öppna balansrapport'
- } else if (/continuity/i.test(blocker)) {
+ if (blocker.code === 'DRAFT_ENTRIES') {
+ // The verifikat list has its own Utkast tab; it does not read a status
+ // query param, so the link goes to the plain list.
href = '/bookkeeping'
+ actionLabel = 'Visa utkast'
+ } else if (blocker.code === 'UNBOOKED_TRANSACTIONS') {
+ // Transaktionslistan is where an unbooked transaction is either booked or
+ // marked private, the two remedies the message names.
+ href = '/transactions'
+ actionLabel = 'Visa transaktioner'
+ } else if (blocker.code === 'TRIAL_BALANCE_UNBALANCED') {
+ href = '/reports/trial-balance'
+ actionLabel = 'Öppna saldobalansen'
+ } else if (blocker.code === 'CONTINUITY_MISMATCH') {
+ // Saldobalansen lists ingående och utgående saldo per konto: the closest
+ // existing surface for reviewing IB against prior-year UB.
+ href = '/reports/trial-balance'
actionLabel = 'Granska ingående balans'
}
return (
-
{blocker}
+
{blocker.message}
{href && actionLabel && (
{actionLabel}
diff --git a/extensions/general/mcp-server/__tests__/year-end-readiness.test.ts b/extensions/general/mcp-server/__tests__/year-end-readiness.test.ts
index 35939cb1..a7e4f113 100644
--- a/extensions/general/mcp-server/__tests__/year-end-readiness.test.ts
+++ b/extensions/general/mcp-server/__tests__/year-end-readiness.test.ts
@@ -2,9 +2,9 @@
* Unit tests for gnubok_year_end_readiness.
*
* Covers tool registration, scope mapping, and the blocker-kind classification
- * heuristic that turns the lib's flat error strings into structured agent-
- * friendly entries. Full integration with validateYearEndReadiness is covered
- * by lib/core/bookkeeping tests + the manual MCP smoke test.
+ * that turns the lib's coded blockers into structured agent-friendly entries.
+ * Full integration with validateYearEndReadiness is covered by
+ * lib/core/bookkeeping tests + the manual MCP smoke test.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { tools } from '../server'
@@ -68,16 +68,28 @@ describe('gnubok_year_end_readiness: execute', () => {
vi.clearAllMocks()
})
- it('classifies common error strings into structured kinds', async () => {
+ it('classifies blocker codes into structured kinds', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue({
ready: false,
+ blockers: [
+ { code: 'DRAFT_ENTRIES', message: '3 utkast måste bokföras eller raderas innan bokslut' },
+ { code: 'UNEXPLAINED_VOUCHER_GAP', message: 'Oförklarat verifikationsnummerglapp i serie A: 5-7' },
+ { code: 'TRIAL_BALANCE_UNBALANCED', message: 'Råbalansen balanserar inte: debet=100, kredit=200' },
+ // Classification is by code, so an off-wording message (here the legacy
+ // English one) must still land on sequence_mismatch.
+ { code: 'SEQUENCE_COUNTER_BEHIND', message: 'Sequence counter integrity error in series A: counter=3 but max voucher=5' },
+ { code: 'UNBOOKED_TRANSACTIONS', message: '3 transaktioner i perioden saknar bokföring: bokför dem eller markera dem som privata innan bokslut' },
+ // The fail-closed variant shares the kind: an agent reacts to both by
+ // going to look at the transactions and re-running readiness.
+ { code: 'UNBOOKED_CHECK_FAILED', message: 'Kontrollen av obokförda transaktioner kunde inte genomföras: försök igen' },
+ ],
errors: [
- // 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',
+ '3 transaktioner i perioden saknar bokföring: bokför dem eller markera dem som privata innan bokslut',
+ 'Kontrollen av obokförda transaktioner kunde inte genomföras: försök igen',
],
warnings: ['Inga bokförda verifikationer i perioden'],
draftCount: 3,
@@ -108,16 +120,58 @@ describe('gnubok_year_end_readiness: execute', () => {
expect(result.ready).toBe(false)
const kinds = result.blockers.map((b) => b.kind)
- expect(kinds).toContain('draft_entries')
- expect(kinds).toContain('unexplained_voucher_gap')
- expect(kinds).toContain('sequence_mismatch')
- expect(kinds).toContain('trial_balance_unbalanced')
+ expect(kinds).toEqual([
+ 'draft_entries',
+ 'unexplained_voucher_gap',
+ 'trial_balance_unbalanced',
+ 'sequence_mismatch',
+ 'unbooked_transactions',
+ 'unbooked_transactions',
+ ])
+ expect(kinds).not.toContain('other')
expect(result.summary).toMatch(/Inte klart/)
})
+ it('falls back to the wording heuristic for an unmapped blocker code', async () => {
+ vi.mocked(validateYearEndReadiness).mockResolvedValue({
+ ready: false,
+ // A code the kind map does not know yet (a future blocker shipped
+ // without a map entry): the message heuristic must still route it.
+ blockers: [
+ { code: 'SOME_FUTURE_CODE' as never, message: 'Råbalansen balanserar inte: debet=100, kredit=200' },
+ { code: 'ANOTHER_FUTURE_CODE' as never, message: 'Något helt nytt gick fel' },
+ ],
+ errors: [
+ 'Råbalansen balanserar inte: debet=100, kredit=200',
+ 'Något helt nytt gick fel',
+ ],
+ warnings: [],
+ draftCount: 0,
+ voucherGaps: [],
+ unexplainedGaps: [],
+ sequenceMismatches: [],
+ trialBalanceBalanced: false,
+ })
+
+ const tool = tools.find((t) => t.name === 'gnubok_year_end_readiness')!
+ const supabase = makeMockSupabase({
+ id: 'period-1', name: '2026',
+ period_start: '2026-01-01', period_end: '2026-12-31',
+ is_closed: false, locked_at: null, closing_entry_id: null, continuity_verified: true,
+ })
+
+ const result = (await tool.execute(
+ { fiscal_period_id: 'period-1' },
+ 'company-1', 'user-1', supabase,
+ )) as { blockers: { kind: string }[] }
+
+ expect(result.blockers.map((b) => b.kind)).toEqual(['trial_balance_unbalanced', 'other'])
+ })
+
it('skips preview when not requested even if ready', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue({
ready: true,
+ blockers: [],
errors: [],
warnings: [],
draftCount: 0,
@@ -148,6 +202,7 @@ describe('gnubok_year_end_readiness: execute', () => {
it('returns the preview when include_preview=true and ready', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue({
ready: true,
+ blockers: [],
errors: [],
warnings: [],
draftCount: 0,
diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts
index 3a487004..0a65bb31 100644
--- a/extensions/general/mcp-server/server.ts
+++ b/extensions/general/mcp-server/server.ts
@@ -195,7 +195,7 @@ import { appendProcessingHistory } from '@/lib/processing-history/append'
import { getUserCompanies } from '@/lib/company/context'
// ensureInitialized() is called by the extension router (ext/[...path]/route.ts)
// which dispatches to this handler: no duplicate call needed here.
-import type { Transaction, TransactionCategory, EntityType, VatTreatment, Invoice, Currency, CompanySettings, Customer, InvoiceItem, PendingOperation, VatPeriodType, VatDeclarationRutor } from '@/types'
+import type { Transaction, TransactionCategory, EntityType, VatTreatment, Invoice, Currency, CompanySettings, Customer, InvoiceItem, PendingOperation, VatPeriodType, VatDeclarationRutor, YearEndBlockerCode } from '@/types'
// ── Actor context ────────────────────────────────────────────
@@ -1713,6 +1713,51 @@ const RC_COMPLETENESS_CODES = new Set([
'RC_INPUT_VAT_MISMATCH',
])
+/**
+ * gnubok_year_end_readiness: YearEndBlockerCode to the blocker `kind` this
+ * tool publishes. The kinds are the public contract agents switch on, so they
+ * are deliberately NOT the codes themselves: a code may be renamed or split
+ * without breaking a consumer, as long as it keeps mapping to the same kind.
+ *
+ * UNBOOKED_CHECK_FAILED shares 'unbooked_transactions' with the real count:
+ * the fail-closed variant means "we could not tell", and an agent should react
+ * to it the same way (go look at the transactions, then re-run readiness).
+ */
+const YEAR_END_BLOCKER_KIND: Record = {
+ PERIOD_NOT_FOUND: 'period_not_found',
+ PERIOD_NOT_ENDED: 'period_not_ended',
+ PERIOD_ALREADY_CLOSED: 'period_already_closed',
+ CLOSING_ENTRY_EXISTS: 'closing_entry_exists',
+ DRAFT_ENTRIES: 'draft_entries',
+ UNEXPLAINED_VOUCHER_GAP: 'unexplained_voucher_gap',
+ SEQUENCE_COUNTER_BEHIND: 'sequence_mismatch',
+ TRIAL_BALANCE_UNBALANCED: 'trial_balance_unbalanced',
+ CONTINUITY_MISMATCH: 'opening_balance_continuity',
+ NEXT_PERIOD_HAS_IB: 'next_period_ib_posted',
+ UNBOOKED_TRANSACTIONS: 'unbooked_transactions',
+ UNBOOKED_CHECK_FAILED: 'unbooked_transactions',
+}
+
+/**
+ * Wording fallback for a blocker whose code is not in YEAR_END_BLOCKER_KIND.
+ * Kept so an unmapped or legacy English message still routes somewhere useful
+ * instead of collapsing to 'other'.
+ */
+function classifyYearEndBlockerMessage(message: string): string {
+ if (/draft journal entries|utkast måste bokföras/i.test(message)) return 'draft_entries'
+ if (/unbooked transaction|saknar bokföring|obokförda transaktioner/i.test(message)) return 'unbooked_transactions'
+ if (/voucher gap|verifikationsnummerglapp/i.test(message)) return 'unexplained_voucher_gap'
+ if (/Sequence counter integrity|Nummerserien i serie/i.test(message)) return 'sequence_mismatch'
+ if (/Trial balance is not balanced|Råbalansen balanserar inte/i.test(message)) return 'trial_balance_unbalanced'
+ if (/already closed|redan stängd/i.test(message)) return 'period_already_closed'
+ if (/has not yet ended|slutdatumet har inte passerat/i.test(message)) return 'period_not_ended'
+ if (/closing entry already exists|Bokslutsverifikation finns redan/i.test(message)) return 'closing_entry_exists'
+ if (/continuity check failed|IB\/UB-kontinuiteten/i.test(message)) return 'opening_balance_continuity'
+ if (/opening balances already posted|redan ingående balanser bokförda/i.test(message)) return 'next_period_ib_posted'
+ if (/Fiscal period not found|Räkenskapsperioden hittades inte/i.test(message)) return 'period_not_found'
+ return 'other'
+}
+
interface VatCloseSanityAnomaly {
kind: 'output_vat_ratio_drift' | 'input_vat_ratio_drift' | 'revenue_drop' | 'revenue_spike'
rate?: '25' | '12' | '6'
@@ -12786,27 +12831,18 @@ export const tools: McpTool[] = [
const validation = await validateYearEndReadiness(supabase, companyId, userId, fiscalPeriodId)
- // 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|utkast måste bokföras/i.test(message)) kind = 'draft_entries'
- else if (/unbooked transaction|saknar bokföring|obokförda transaktioner/i.test(message)) kind = 'unbooked_transactions'
- 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 (/opening balances already posted|redan ingående balanser bokförda/i.test(message)) kind = 'next_period_ib_posted'
- else if (/Fiscal period not found|Räkenskapsperioden hittades inte/i.test(message)) kind = 'period_not_found'
- return { kind, severity: 'high' as const, message }
- })
+ // Reshape the lib's blockers into structured entries so the agent (and
+ // any dashboard) can render and act on each one independently. Routing
+ // keys off the stable YearEndBlockerCode via YEAR_END_BLOCKER_KIND, so a
+ // reworded Swedish message no longer silently reclassifies as 'other'.
+ // The `kind` strings are this tool's public contract: never rename one.
+ // A blocker with no mapped code falls back to the wording heuristic
+ // (which also catches legacy English messages), then to 'other'.
+ const blockers = validation.blockers.map(({ code, message }) => ({
+ kind: YEAR_END_BLOCKER_KIND[code] ?? classifyYearEndBlockerMessage(message),
+ severity: 'high' as const,
+ message,
+ }))
let preview = null
if (includePreview && validation.ready) {
diff --git a/lib/bokslut/__tests__/readiness-aggregator.test.ts b/lib/bokslut/__tests__/readiness-aggregator.test.ts
index 2ef1c4f7..d2ba9848 100644
--- a/lib/bokslut/__tests__/readiness-aggregator.test.ts
+++ b/lib/bokslut/__tests__/readiness-aggregator.test.ts
@@ -78,6 +78,7 @@ function makeSupabase(handlers: {
function baseValidation(overrides: Partial = {}): YearEndValidation {
return {
ready: true,
+ blockers: [],
errors: [],
warnings: [],
draftCount: 0,
@@ -147,6 +148,7 @@ describe('buildBokslutReadinessReport', () => {
expect(report.ready).toBe(true)
expect(report.blockers).toEqual([])
+ expect(report.blockerItems).toEqual([])
expect(report.entityType).toBe('aktiebolag')
// Phase 3 handles depreciation + bolagsskatt + p-fond automatically: only
// the accruals reminder should remain (Phase 4 will replace it).
@@ -210,6 +212,9 @@ describe('buildBokslutReadinessReport', () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue(
baseValidation({
ready: false,
+ blockers: [
+ { code: 'DRAFT_ENTRIES', message: '3 utkast måste bokföras eller raderas innan bokslut' },
+ ],
errors: ['3 utkast måste bokföras eller raderas innan bokslut'],
draftCount: 3,
}),
@@ -224,6 +229,11 @@ describe('buildBokslutReadinessReport', () => {
expect(report.ready).toBe(false)
expect(report.blockers).toHaveLength(1)
+ // The code+message pairs pass through untouched so the wizard can match
+ // remediation links on the stable code.
+ expect(report.blockerItems).toEqual([
+ { code: 'DRAFT_ENTRIES', message: '3 utkast måste bokföras eller raderas innan bokslut' },
+ ])
expect(report.draftCount).toBe(3)
})
diff --git a/lib/bokslut/readiness-aggregator.ts b/lib/bokslut/readiness-aggregator.ts
index b1613fa5..e431866d 100644
--- a/lib/bokslut/readiness-aggregator.ts
+++ b/lib/bokslut/readiness-aggregator.ts
@@ -6,7 +6,7 @@ import { generateARReconciliation } from '@/lib/reports/ar-reconciliation'
import { generateReconciliation as generateAPReconciliation } from '@/lib/reports/supplier-reconciliation'
import { computeEfDeclarationPreview } from '@/lib/bokslut/enskild-firma/ef-declaration-preview'
import { createLogger } from '@/lib/logger'
-import type { YearEndValidation } from '@/types'
+import type { YearEndBlocker, YearEndValidation } from '@/types'
const log = createLogger('bokslut-readiness')
@@ -27,6 +27,10 @@ export interface BokslutReadinessReport {
ready: boolean
/** Blocking errors that prevent year-end execution (from year-end-service). */
blockers: string[]
+ /** Same blockers with stable machine codes (same order as `blockers`).
+ * The wizard matches on `code` to attach remediation links; `blockers`
+ * stays as plain strings for existing consumers. */
+ blockerItems: YearEndBlocker[]
/** Non-blocking warnings (from year-end-service). */
warnings: string[]
/** Soft reminders (Phase 2+ features not yet shipped, manual steps the user
@@ -247,6 +251,7 @@ export async function buildBokslutReadinessReport(
return {
ready: validation.ready,
blockers: validation.errors,
+ blockerItems: validation.blockers,
warnings: validation.warnings,
reminders,
draftCount: validation.draftCount,
diff --git a/lib/core/bookkeeping/__tests__/year-end-service.test.ts b/lib/core/bookkeeping/__tests__/year-end-service.test.ts
index 43bec603..84ae832f 100644
--- a/lib/core/bookkeeping/__tests__/year-end-service.test.ts
+++ b/lib/core/bookkeeping/__tests__/year-end-service.test.ts
@@ -282,6 +282,44 @@ 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('utkast'))).toBe(true)
+ expect(result.blockers.some((b) => b.code === 'DRAFT_ENTRIES')).toBe(true)
+ // errors is the message mirror of blockers: same order, same strings.
+ expect(result.errors).toEqual(result.blockers.map((b) => b.message))
+ })
+
+ it('returns a coded PERIOD_NOT_FOUND blocker when the period is missing', async () => {
+ results = [{ data: null, error: { message: 'not found' } }]
+
+ const supabase = makeClient()
+ const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-x')
+ expect(result.ready).toBe(false)
+ expect(result.blockers).toEqual([
+ { code: 'PERIOD_NOT_FOUND', message: 'Räkenskapsperioden hittades inte' },
+ ])
+ expect(result.errors).toEqual(['Räkenskapsperioden hittades inte'])
+ })
+
+ it('codes closed-period, existing closing entry, and continuity blockers', async () => {
+ const period = {
+ ...makeFiscalPeriod({ id: 'fp-1', is_closed: true, closing_entry_id: 'ce-1' }),
+ continuity_verified: false,
+ }
+ results = noGapResults(period)
+
+ vi.mocked(generateTrialBalance).mockResolvedValue({
+ rows: [],
+ isBalanced: true,
+ totalDebit: 0,
+ totalCredit: 0,
+ } as never)
+
+ const supabase = makeClient()
+ const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
+ expect(result.ready).toBe(false)
+ const codes = result.blockers.map((b) => b.code)
+ expect(codes).toContain('PERIOD_ALREADY_CLOSED')
+ expect(codes).toContain('CLOSING_ENTRY_EXISTS')
+ expect(codes).toContain('CONTINUITY_MISMATCH')
})
it('returns errors when trial balance is unbalanced', async () => {
@@ -300,6 +338,7 @@ describe('validateYearEndReadiness', () => {
expect(result.ready).toBe(false)
expect(result.trialBalanceBalanced).toBe(false)
expect(result.errors.some((e: string) => e.includes('Råbalansen balanserar inte'))).toBe(true)
+ expect(result.blockers.some((b) => b.code === 'TRIAL_BALANCE_UNBALANCED')).toBe(true)
})
it('returns error when period has not yet ended', async () => {
@@ -322,6 +361,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('slutdatumet har inte passerat'))).toBe(true)
+ expect(result.blockers.some((b) => b.code === 'PERIOD_NOT_ENDED')).toBe(true)
})
it('blocks when the period contains unbooked bank transactions', async () => {
@@ -344,6 +384,10 @@ describe('validateYearEndReadiness', () => {
expect(result.ready).toBe(false)
expect(result.unbookedTransactionCount).toBe(3)
expect(result.errors.some((e: string) => e.includes('3 transaktioner i perioden saknar bokföring'))).toBe(true)
+ // The code is what the wizard and the MCP tool route on: losing it would
+ // silently drop the remediation link and the 'unbooked_transactions' kind.
+ expect(result.blockers.some((b) => b.code === 'UNBOOKED_TRANSACTIONS')).toBe(true)
+ expect(result.errors).toEqual(result.blockers.map((b) => b.message))
})
it('fails closed when the unbooked-transaction check cannot run', async () => {
@@ -366,6 +410,8 @@ describe('validateYearEndReadiness', () => {
e.includes('Kontrollen av obokförda transaktioner kunde inte genomföras'),
),
).toBe(true)
+ expect(result.blockers.some((b) => b.code === 'UNBOOKED_CHECK_FAILED')).toBe(true)
+ expect(result.errors).toEqual(result.blockers.map((b) => b.message))
})
it('warns on explained voucher gaps', async () => {
@@ -447,6 +493,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('Oförklarat verifikationsnummerglapp'))).toBe(true)
+ expect(result.blockers.some((b) => b.code === 'UNEXPLAINED_VOUCHER_GAP')).toBe(true)
expect(result.unexplainedGaps).toHaveLength(1)
expect(result.unexplainedGaps[0]).toEqual({ gap_start: 5, gap_end: 7, series: 'A' })
})
@@ -528,6 +575,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('Nummerserien i serie'))).toBe(true)
+ expect(result.blockers.some((b) => b.code === 'SEQUENCE_COUNTER_BEHIND')).toBe(true)
expect(result.sequenceMismatches).toHaveLength(1)
expect(result.sequenceMismatches[0]).toEqual({ series: 'A', sequenceCounter: 5, actualMax: 10 })
})
@@ -610,6 +658,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('redan ingående balanser bokförda'))).toBe(true)
+ expect(result.blockers.some((b) => b.code === 'NEXT_PERIOD_HAS_IB')).toBe(true)
})
})
diff --git a/lib/core/bookkeeping/year-end-service.ts b/lib/core/bookkeeping/year-end-service.ts
index 8133cbf8..870d4a2c 100644
--- a/lib/core/bookkeeping/year-end-service.ts
+++ b/lib/core/bookkeeping/year-end-service.ts
@@ -28,6 +28,7 @@ import {
import { validateBalanceContinuity } from '@/lib/reports/continuity-check'
import type {
YearEndValidation,
+ YearEndBlocker,
YearEndPreview,
YearEndResult,
CreateJournalEntryLineInput,
@@ -47,7 +48,10 @@ export async function validateYearEndReadiness(
userId: string,
fiscalPeriodId: string
): Promise {
- const errors: string[] = []
+ // Each blocker carries a stable machine code (YearEndBlockerCode) that the
+ // bokslut wizard matches on to attach remediation links; `errors` mirrors
+ // the messages for consumers of the plain string list.
+ const blockers: YearEndBlocker[] = []
const warnings: string[] = []
// Fetch the period
@@ -58,14 +62,20 @@ export async function validateYearEndReadiness(
.eq('company_id', companyId)
.single()
- // The error/warning strings below are Swedish: they render verbatim in the
+ // The blocker/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.
+ // Blockers are routed on `code`, never on the wording, so rewording a
+ // message is safe. Adding a NEW code is not: the MCP year_end_readiness tool
+ // maps every YearEndBlockerCode to its public `kind`, so a new code needs a
+ // matching entry in extensions/general/mcp-server/server.ts.
if (fetchError || !period) {
+ const notFound: YearEndBlocker[] = [
+ { code: 'PERIOD_NOT_FOUND', message: 'Räkenskapsperioden hittades inte' },
+ ]
return {
ready: false,
- errors: ['Räkenskapsperioden hittades inte'],
+ blockers: notFound,
+ errors: notFound.map((b) => b.message),
warnings: [],
draftCount: 0,
voucherGaps: [],
@@ -78,17 +88,23 @@ 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('Perioden kan inte stängas: slutdatumet har inte passerat ännu')
+ blockers.push({
+ code: 'PERIOD_NOT_ENDED',
+ message: 'Perioden kan inte stängas: slutdatumet har inte passerat ännu',
+ })
}
// Check: period not already closed
if (period.is_closed) {
- errors.push('Perioden är redan stängd')
+ blockers.push({ code: 'PERIOD_ALREADY_CLOSED', message: 'Perioden är redan stängd' })
}
// Check: closing entry doesn't already exist
if (period.closing_entry_id) {
- errors.push('Bokslutsverifikation finns redan för perioden')
+ blockers.push({
+ code: 'CLOSING_ENTRY_EXISTS',
+ message: 'Bokslutsverifikation finns redan för perioden',
+ })
}
// Check: no draft entries
@@ -101,7 +117,10 @@ export async function validateYearEndReadiness(
const drafts = draftCount ?? 0
if (drafts > 0) {
- errors.push(`${drafts} utkast måste bokföras eller raderas innan bokslut`)
+ blockers.push({
+ code: 'DRAFT_ENTRIES',
+ message: `${drafts} utkast måste bokföras eller raderas innan bokslut`,
+ })
}
// Check: voucher continuity across all series
@@ -156,9 +175,10 @@ export async function validateYearEndReadiness(
)
} else {
unexplainedGaps.push(gap)
- errors.push(
- `Oförklarat verifikationsnummerglapp i serie ${gap.series}: ${gap.gap_start}-${gap.gap_end}`
- )
+ blockers.push({
+ code: 'UNEXPLAINED_VOUCHER_GAP',
+ message: `Oförklarat verifikationsnummerglapp i serie ${gap.series}: ${gap.gap_start}-${gap.gap_end}`,
+ })
}
}
}
@@ -197,9 +217,10 @@ export async function validateYearEndReadiness(
})
if (sequenceCounter < actualMax) {
- errors.push(
- `Nummerserien i serie ${row.voucher_series} stämmer inte: räknaren står på ${sequenceCounter} men högsta verifikationsnummer är ${actualMax}`
- )
+ blockers.push({
+ code: 'SEQUENCE_COUNTER_BEHIND',
+ message: `Nummerserien i serie ${row.voucher_series} stämmer inte: räknaren står på ${sequenceCounter} men högsta verifikationsnummer är ${actualMax}`,
+ })
} else {
warnings.push(
`Nummerräknaren ligger före bokförda verifikationer i serie ${row.voucher_series}: räknare=${sequenceCounter}, högsta verifikationsnummer=${actualMax}`
@@ -214,9 +235,10 @@ export async function validateYearEndReadiness(
const trialBalanceBalanced = trialBalance.isBalanced
if (!trialBalanceBalanced) {
- errors.push(
- `Råbalansen balanserar inte: debet=${trialBalance.totalDebit}, kredit=${trialBalance.totalCredit}`
- )
+ blockers.push({
+ code: 'TRIAL_BALANCE_UNBALANCED',
+ message: `Råbalansen balanserar inte: debet=${trialBalance.totalDebit}, kredit=${trialBalance.totalCredit}`,
+ })
}
// Check: at least some entries exist
@@ -291,7 +313,10 @@ export async function validateYearEndReadiness(
// Check: continuity_verified flag from prior year-end
if (period.continuity_verified === false) {
- errors.push('IB/UB-kontinuiteten stämmer inte för perioden: åtgärda avvikelserna innan bokslut')
+ blockers.push({
+ code: 'CONTINUITY_MISMATCH',
+ message: '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,
@@ -307,7 +332,10 @@ export async function validateYearEndReadiness(
const nextPeriod = await findNextPeriod(supabase, companyId, fiscalPeriodId)
if (nextPeriod) {
if (nextPeriod.opening_balance_entry_id) {
- errors.push('Nästa räkenskapsperiod har redan ingående balanser bokförda')
+ blockers.push({
+ code: 'NEXT_PERIOD_HAS_IB',
+ message: 'Nästa räkenskapsperiod har redan ingående balanser bokförda',
+ })
} else {
warnings.push('Nästa räkenskapsperiod finns redan: ingående balanser bokförs i den')
}
@@ -331,20 +359,23 @@ export async function validateYearEndReadiness(
)
unbookedTransactionCount = unbooked.untriaged + unbooked.businessUnbooked
if (unbookedTransactionCount > 0) {
- errors.push(
- `${unbookedTransactionCount} transaktioner i perioden saknar bokföring: bokför dem eller markera dem som privata innan bokslut`,
- )
+ blockers.push({
+ code: 'UNBOOKED_TRANSACTIONS',
+ message: `${unbookedTransactionCount} transaktioner i perioden saknar bokföring: bokför dem eller markera dem som privata innan bokslut`,
+ })
}
} catch (err) {
log.warn('unbooked-transaction readiness check failed', err as Error)
- errors.push(
- 'Kontrollen av obokförda transaktioner kunde inte genomföras: försök igen',
- )
+ blockers.push({
+ code: 'UNBOOKED_CHECK_FAILED',
+ message: 'Kontrollen av obokförda transaktioner kunde inte genomföras: försök igen',
+ })
}
return {
- ready: errors.length === 0,
- errors,
+ ready: blockers.length === 0,
+ blockers,
+ errors: blockers.map((b) => b.message),
warnings,
draftCount: drafts,
voucherGaps,
diff --git a/types/index.ts b/types/index.ts
index 30da1f8b..b224b88e 100644
--- a/types/index.ts
+++ b/types/index.ts
@@ -3399,8 +3399,38 @@ export interface SequenceMismatch {
// Year-End Closing Types (Årsbokslut)
// ============================================================
+/**
+ * Stable machine codes for year-end readiness blockers. One code per
+ * blockers.push site in validateYearEndReadiness: the wizard matches on
+ * these to attach remediation links, so codes must never be renamed once
+ * shipped. The Swedish message stays the display text.
+ */
+export type YearEndBlockerCode =
+ | 'PERIOD_NOT_FOUND'
+ | 'PERIOD_NOT_ENDED'
+ | 'PERIOD_ALREADY_CLOSED'
+ | 'CLOSING_ENTRY_EXISTS'
+ | 'DRAFT_ENTRIES'
+ | 'UNEXPLAINED_VOUCHER_GAP'
+ | 'SEQUENCE_COUNTER_BEHIND'
+ | 'TRIAL_BALANCE_UNBALANCED'
+ | 'CONTINUITY_MISMATCH'
+ | 'NEXT_PERIOD_HAS_IB'
+ | 'UNBOOKED_TRANSACTIONS'
+ | 'UNBOOKED_CHECK_FAILED'
+
+export interface YearEndBlocker {
+ code: YearEndBlockerCode
+ /** Swedish, user-facing: bokslut is a stays-Swedish surface. */
+ message: string
+}
+
export interface YearEndValidation {
ready: boolean
+ /** Blocking errors with stable machine codes. */
+ blockers: YearEndBlocker[]
+ /** Blocker messages only; mirrors `blockers`. Kept so existing consumers
+ * of the string list (v1 compliance check, MCP tool) stay unchanged. */
errors: string[]
warnings: string[]
draftCount: number