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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
31fba5e8f9
commit
78a37f6396
@@ -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,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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user