Files
accounted/extensions/general/mcp-server/__tests__/year-end-readiness.test.ts
T
5b0ca3d874 fix(copy): make K3 and year-end claims match what the code actually does (#1431)
* fix(copy): make K3, leasing and year-end claims match what the code does

Follow-up to the batch that removed the uppskjuten-skatt posting on
obeskattade reserver (K3 29.37 gross in juridisk person) and added the K2
asset-account gate. Six user-facing strings still described the old
behaviour or made claims the code cannot support.

1. Arsredovisning page: the K3 explainer promised an uppskjuten skatt-not
   and a materiella anlaggningstillgangar-not in every K3 document. Both
   are conditional (a 2240/8940 balance, assets in the register) and the
   first is now absent in the normal case. The kassaflodesanalys is
   dropped with a warning when it cannot be generated, so it is named
   only when the document actually carries one.

2. Regelverk settings: kassaflodesanalys was presented as following from
   K3. It follows from being ett storre foretag
   (swedish-year-end-closing/references/reporting-and-filing.md:10,
   legal-framework.md:42); the copy now says the product includes one and
   states the storre-foretag rule separately. Komponentavskrivning was
   presented as optional under K3; it is mandatory where component useful
   lives differ materially (k2-vs-k3.md:5, asset-accounting
   references/depreciation.md:33).

3. Note 1 and the Uppskjutna skatter-not no longer claim the 2240 balance
   is hanforlig till obeskattade reserver. deriveLatentTaxMovement reads
   the 2240/8940 balances only, and under K3 that account carries deferred
   tax on all temporary differences (k2-vs-k3.md:11-13).

4. The deferredTax 'unknown' branch emitted the gross-reserve statement,
   which is the denial phrased positively: the same affirmative claim
   about books that could not be read. It now emits no deferred-tax
   paragraph at all; build-data already warns on that path.

5. Capitalized-lease detection looked at 1260/1269 only. On the shipped
   BAS 2026 chart 1260 is a free inventarier account and 1269 is ack.
   avskrivningar pa datorer, so owned computers were reported as leased,
   while 1217/1227 (finansiellt leasade) were missed. Detection now reads
   the company's own account names in kontogrupp 12, which is where BAS
   keeps capitalized leases (leasing-and-disposal.md:28) and which owned
   inventarier on 1220 never matches. 1720 forutbetalda leasingavgifter
   stays out: that is the operational treatment.

6a. gnubok_year_end_readiness listed FX revaluation as a blocker (it is a
   warning) and omitted UNBOOKED_TRANSACTIONS, the common one. The
   description now names every actionable blocker kind, within the
   280-char budget, and a test pins it against YEAR_END_BLOCKER_KIND.

6b. companies.accounting_framework defaults to 'k2', so every enskild
   firma hit the K2 asset gate and was handed a BFNAR 2016:10 punkt 10.4
   citation plus a K3 remedy it cannot take: a sole trader prepares ett
   forenklat arsbokslut, not an arsredovisning (legal-framework.md:29,
   :48). entity_type now rides along on the companies read the routes
   already do, and non-AB entities get wording with no citation and no
   K3, keeping the 1090 remedy. The K1 counterpart of punkt 10.4 is not
   sourced in the repo skills, so nothing was invented in its place.

* fix(copy): close the review findings on the copy-truth sweep

Three follow-ups from the source and code reviews. (1) The K2/K3 help text had upgraded a vague sentence into a definite boundary claim ('gransen gar vid <trosklar>'), which excludes the other routes into mandatory K3 that are live right now for this control's audience: noterade vardepapper, and from fiscal years starting after 2025-12-31 also utlandsk filial, kryptotillgangar, aktierelaterade ersattningar and fastighetsbolag. An AB in one of those categories would have read the sentence and stayed on a regelverk it may no longer use. (2) hasCapitalizedLeaseAsset compared per-side cumulative totals, so a lease acquired earlier and disposed this year still claimed the balance sheet carries a leased asset; it now compares the net balance. (3) The K3 warning enumerated a kassaflodesanalys the document may not contain, contradicting the newly conditional page copy on the same screen.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 11:54:57 +02:00

278 lines
11 KiB
TypeScript

/**
* Unit tests for gnubok_year_end_readiness.
*
* Covers tool registration, scope mapping, and the blocker-kind classification
* 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, YEAR_END_BLOCKER_KIND } from '../server'
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
vi.mock('@/lib/core/bookkeeping/year-end-service', () => ({
validateYearEndReadiness: vi.fn(),
previewYearEndClosing: vi.fn(),
}))
import {
validateYearEndReadiness,
previewYearEndClosing,
} from '@/lib/core/bookkeeping/year-end-service'
describe('gnubok_year_end_readiness: registration', () => {
it('is registered in the tools array', () => {
const tool = tools.find((t) => t.name === 'gnubok_year_end_readiness')
expect(tool).toBeDefined()
expect(tool?.annotations.readOnlyHint).toBe(true)
expect(tool?.annotations.destructiveHint).toBe(false)
expect(tool?.annotations.idempotentHint).toBe(true)
})
it('requires fiscal_period_id', () => {
const tool = tools.find((t) => t.name === 'gnubok_year_end_readiness')!
const schema = tool.inputSchema as { required?: string[] }
expect(schema.required).toContain('fiscal_period_id')
})
it('declares output schema with intent fields', () => {
const tool = tools.find((t) => t.name === 'gnubok_year_end_readiness')!
const schema = tool.outputSchema as { required?: string[] }
expect(schema.required).toContain('ready')
expect(schema.required).toContain('blockers')
expect(schema.required).toContain('warnings')
expect(schema.required).toContain('summary')
})
it('is mapped to reports:read scope', () => {
expect(TOOL_SCOPE_MAP.gnubok_year_end_readiness).toBe('reports:read')
})
// The description is the only thing an agent reads before deciding what to
// pre-check, and the 280-char budget does not fit all eleven kinds. It must
// therefore name every kind the caller can DO something about ahead of time;
// the four period-state kinds are summarized, since nothing can be
// pre-checked about them.
const PERIOD_STATE_KINDS = new Set([
'period_not_found',
'period_not_ended',
'period_already_closed',
'closing_entry_exists',
])
it('names every actionable blocker kind the tool can emit', () => {
const tool = tools.find((t) => t.name === 'gnubok_year_end_readiness')!
const actionable = [...new Set(Object.values(YEAR_END_BLOCKER_KIND))].filter(
(kind) => !PERIOD_STATE_KINDS.has(kind),
)
// Guards the summarizing itself: if a kind stops being period-state, or a
// new one appears, it has to show up in the description.
expect(actionable.length).toBe(7)
for (const kind of actionable) {
expect(tool.description, `blocker kind ${kind} missing from description`).toContain(kind)
}
expect(tool.description).toContain('period-state')
})
it('names unbooked transactions as the most common blocker', () => {
// The omission that sent agents pre-checking the wrong things: this is the
// blocker a real close trips on, and the description never mentioned it.
const tool = tools.find((t) => t.name === 'gnubok_year_end_readiness')!
expect(tool.description).toMatch(/unbooked_transactions \(most common\)/)
})
it('does not present open foreign-currency items as a blocker', () => {
// validateYearEndReadiness pushes those onto `warnings`, never `blockers`:
// executeYearEndClosing runs the revaluation itself, so escalating would
// block a close that the very next step performs.
const tool = tools.find((t) => t.name === 'gnubok_year_end_readiness')!
expect(tool.description).toMatch(/FX = warning, never blocker/)
expect(tool.description).not.toMatch(/[Bb]lockers[^.]*revaluation/)
})
})
function makeMockSupabase(period: Record<string, unknown> | null) {
return {
from: vi.fn().mockReturnValue({
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({ data: period, error: null }),
}),
}),
}),
}),
} as never
}
describe('gnubok_year_end_readiness: execute', () => {
beforeEach(() => {
vi.clearAllMocks()
})
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: [
'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',
'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,
voucherGaps: [{ series: 'A', gap_start: 5, gap_end: 7 }],
unexplainedGaps: [{ series: 'A', gap_start: 5, gap_end: 7 }],
sequenceMismatches: [{ series: 'A', sequenceCounter: 3, actualMax: 5 }],
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 { ready: boolean; blockers: { kind: string }[]; summary: string }
expect(result.ready).toBe(false)
const kinds = result.blockers.map((b) => b.kind)
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,
voucherGaps: [],
unexplainedGaps: [],
sequenceMismatches: [],
trialBalanceBalanced: true,
})
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 { ready: boolean; preview: unknown; summary: string }
expect(result.ready).toBe(true)
expect(result.preview).toBeNull()
expect(vi.mocked(previewYearEndClosing)).not.toHaveBeenCalled()
expect(result.summary).toMatch(/Klart för bokslut/)
})
it('returns the preview when include_preview=true and ready', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue({
ready: true,
blockers: [],
errors: [],
warnings: [],
draftCount: 0,
voucherGaps: [],
unexplainedGaps: [],
sequenceMismatches: [],
trialBalanceBalanced: true,
})
vi.mocked(previewYearEndClosing).mockResolvedValue({
net_result: 12345,
closing_account: '2099',
lines: [],
} as never)
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', include_preview: true },
'company-1', 'user-1', supabase,
)) as { preview: { net_result?: number } | null }
expect(result.preview).not.toBeNull()
expect(result.preview?.net_result).toBe(12345)
})
})