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
@@ -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,
|
||||
|
||||
@@ -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<VatDeclarationCheck['code']>([
|
||||
'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<YearEndBlockerCode, string> = {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user