fix(year-end): typed preflight blocker codes so remediation links render (#1420)

* fix(year-end): typed preflight blocker codes so remediation links render

validateYearEndReadiness emits Swedish blocker strings but the wizard's
BlockerRow matched English phrases, so no remediation link ever rendered,
and the voucher-gap branch pointed at /bookkeeping/voucher-gaps which only
exists as an API route. Blockers now carry stable machine codes end to end
(YearEndBlockerCode on YearEndValidation.blockers, mirrored additively as
blockerItems on BokslutReadinessReport); errors stays the plain string
mirror so the v1 compliance check and MCP tool keep their exact shapes.
BlockerRow matches on code and links only to pages that exist; the
voucher-gap and dead-link branches are removed.

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

* fix(year-end): code the unbooked-transaction blockers #1414 added

#1414 landed two new blockers in validateYearEndReadiness using the old
errors.push style, which this branch had already renamed to a typed
blockers array. Merging main left them referencing a variable that no
longer exists.

Converted both to the typed scheme: UNBOOKED_TRANSACTIONS (the safety
guard that stops executeYearEndClosing from aborting at the step 7 lock
AFTER the closing entry posted at step 4) and UNBOOKED_CHECK_FAILED (the
fail-closed variant). Neither behaviour changes; both keep their Swedish
wording verbatim.

The MCP year_end_readiness classifier now routes on the stable
YearEndBlockerCode instead of regexing the Swedish message, with the
wording heuristic kept as a fallback for an unmapped or legacy English
message. The public `kind` values are unchanged, so MCP consumers see the
same output; both new codes map to 'unbooked_transactions' as before,
since an agent reacts to "we could not tell" the same way it reacts to a
real count.

UNBOOKED_TRANSACTIONS gets a /transactions remediation link in the
preflight step: that page is where a transaction is booked or marked
private, the two remedies the message names. UNBOOKED_CHECK_FAILED gets
none: the remedy is to re-run the check.

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-06 10:04:42 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 31fba5e8f9
commit 78a37f6396
8 changed files with 315 additions and 78 deletions
@@ -78,6 +78,7 @@ function makeSupabase(handlers: {
function baseValidation(overrides: Partial<YearEndValidation> = {}): 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)
})
+6 -1
View File
@@ -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,
@@ -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)
})
})
+59 -28
View File
@@ -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<YearEndValidation> {
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,