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 && (
- {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