* 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>
181 lines
6.2 KiB
TypeScript
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)
|
|
})
|
|
})
|