Files
accounted/extensions/general/mcp-server/__tests__/year-end-readiness.test.ts
T
MattssonandClaude Fable 5 4e47335308 feat(year-end): administrative undo of executed year-end closing + skatteverket scope fixes (#1081)
* fix(skatteverket): request the ska scope for skattekonto v2

The skattekonto v2 API rejects skahmst-only tokens with 403 "The required
scopes are not authorized" (observed in prod 2026-07-20; no company has
synced since 2026-05-10). The requested `skattekonto` scope is silently
dropped from every grant, while `ska` appears in one real May grant, so
request it too: SKV grants the intersection, so this is harmless if wrong.

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

* fix(skatteverket): correct the skattekonto scope model around ska

Root cause of the May 10 skattekonto outage, confirmed via git history and
prod token data: the `ska` scope (the interactive skattekonto API's actual
scope, requested since the extension's first commit in March) was removed
by the "remove unused scopes" cleanup in the #431 series. Every token
issued after that hour lacks it and the API answers 403 "The required
scopes are not authorized"; no company has synced since. The May 15 repair
re-added skahmst, which per its tjanstebeskrivning is a different bulk
E-transport service and does not substitute; `skattekonto` is not a real
SKV scope name and is silently dropped from grants.

Follow-up to the ska re-request (cd8f7a30):
- document the confirmed scope model in oauth.ts so ska is never
  "cleaned up" again
- panel missing-scope warning and reconnect-button now gate on ska,
  not skahmst/skattekonto
- scope badge labels: ska takes the saldo & transaktioner label,
  skahmst relabeled as the E-transport file service
- consent-page note covers both terse scope names and says ska is
  required

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

* fix(year-end): warn on untaxed profit at verkstall, Swedish readiness messages, always-visible period selector

An aktiebolag could execute year-end with a profit and zero bolagsskatt
booked without any warning (support case: closing moved 592k to 2099
untaxed). The preview now computes bolagsskattMissing (AB + profit + no
89xx account among closed accounts, 8999 excluded) and both the preview
and execute steps render an advisory, bypassable warning.

validateYearEndReadiness messages are now Swedish (the bokslut wizard is
a stays-Swedish surface); the MCP year_end_readiness classifier matches
both the new Swedish strings and the legacy English ones.

The wizard period selector now always renders, keeps a selected-but-
ineligible period selectable, and resets a stale ?period= id from
another company instead of leaving the user stuck on the wrong year.

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

* feat(year-end): administrative undo of an executed year-end closing

Storno-only reset used when a bokslut was executed prematurely (e.g.
without bolagsskatt) and no arsredovisning exists yet: reverses the next
period's result_appropriation and opening_balance entries, reopens the
period, reverses the closing entry, and detaches closing_entry_id.
Resumable if interrupted midway; attribution per BFL 5 kap 6.

Migration 20260720140000 adds the trigger escape hatch: closing_entry_id
may only change once set when the old closing entry is reversed with a
posted storno chain (status flag alone is forgeable via PostgREST), and
a non-NULL replacement must be a posted year_end entry in the same
period. Covered by a pg-real test.

planResultAppropriation idempotency is now posted-only: a reversed
omforing no longer blocks the re-run from posting a fresh 2099 -> 2098
reclassification (it previously returned null silently, leaving the new
year's equity polluted).

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

* fix(review): address CodeRabbit, PR-Agent and compliance findings

- undo script: company_id filters on verify queries, period-scope the
  arsredovisning precondition checks, validate service-key format,
  escalate audit_log insert failure to a hard error (BFNAR 2013:2)
- detach migration: company-scope the storno chain EXISTS, replace the
  em dash in the new error message

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

* fix(review): address round-2 compliance swarm and Swedish review findings

- undo script: require --confirm-url with --commit so an env swap fails
  loud; retry the audit_log insert 3x and direct the operator to insert
  the behandlingshistorik row manually on final failure (BFNAR 2013:2)
- year-end preview: document why resultAccountSummary is a complete 89xx
  scan; warning text now also names periodiseringsfond and
  overavskrivningar as legitimate zero-tax reasons

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 16:17:43 +02:00

181 lines
6.2 KiB
TypeScript

/**
* 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.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { tools } 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')
})
})
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 common error strings into structured kinds', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue({
ready: false,
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',
],
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).toContain('draft_entries')
expect(kinds).toContain('unexplained_voucher_gap')
expect(kinds).toContain('sequence_mismatch')
expect(kinds).toContain('trial_balance_unbalanced')
expect(result.summary).toMatch(/Inte klart/)
})
it('skips preview when not requested even if ready', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue({
ready: true,
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,
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)
})
})