feat: stage kontantmetod year-end cutoff (#1586)

* feat: stage kontantmetod year-end cutoff

* fix: keep cutoff tool payload searchable

* fix: trim year-end tool metadata
This commit is contained in:
Mattsson
2026-08-13 15:55:01 +02:00
committed by GitHub
parent 9be20b4274
commit 73c63209f1
20 changed files with 1460 additions and 136 deletions
@@ -0,0 +1,161 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/core/bookkeeping/period-service', async () => {
const actual = await vi.importActual<typeof import('@/lib/core/bookkeeping/period-service')>(
'@/lib/core/bookkeeping/period-service',
)
return { ...actual, findNextPeriod: vi.fn() }
})
vi.mock('@/lib/core/bookkeeping/kontantmetod-cutoff', async () => {
const actual = await vi.importActual<
typeof import('@/lib/core/bookkeeping/kontantmetod-cutoff')
>('@/lib/core/bookkeeping/kontantmetod-cutoff')
return { ...actual, assessKontantmetodCutoff: vi.fn() }
})
import { tools, deriveToolMeta } from '../server'
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
import { findNextPeriod } from '@/lib/core/bookkeeping/period-service'
import {
assessKontantmetodCutoff,
buildCutoffLines,
} from '@/lib/core/bookkeeping/kontantmetod-cutoff'
const tool = tools.find((candidate) => candidate.name === 'gnubok_post_kontantmetod_cutoff')!
function makeSupabase(settings: Record<string, unknown> = {
accounting_method: 'cash', entity_type: 'aktiebolag',
}) {
const inserts: unknown[] = []
const rows: Record<string, unknown> = {
fiscal_periods: {
id: 'fp-1', name: '2026', period_start: '2026-01-01', period_end: '2026-12-31',
is_closed: false, locked_at: null,
},
company_settings: settings,
pending_operations: { id: 'op-1' },
}
const from = vi.fn((table: string) => {
const chain: Record<string, unknown> = {}
for (const name of ['select', 'eq', 'in', 'gte', 'lte', 'order', 'limit']) {
chain[name] = () => chain
}
chain.insert = (value: unknown) => {
inserts.push(value)
return chain
}
chain.maybeSingle = async () => ({ data: rows[table] ?? null, error: null })
chain.single = async () => ({ data: rows[table] ?? null, error: null })
chain.then = (resolve: (value: unknown) => unknown) =>
resolve({ data: rows[table] ?? null, error: null })
return chain
})
return { auth: {}, from, inserts }
}
const collection = {
receivables: [{
id: 'inv-1', reference: 'F-1', vatTreatment: 'standard_25' as const,
outstanding: 1250, vat: 250,
}],
payables: [{
id: 'si-1', reference: 'L-1', outstanding: 625, vat: 125,
netByAccount: [{ account: '5410', amount: 500 }],
}],
unknownVatTreatment: [],
strayVatOnZeroRate: [],
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(findNextPeriod).mockResolvedValue({
id: 'fp-2', period_start: '2027-01-01', period_end: '2027-12-31',
is_closed: false, locked_at: null,
} as never)
vi.mocked(assessKontantmetodCutoff).mockResolvedValue({
collection,
lines: buildCutoffLines(collection.receivables, collection.payables),
postings: {
complete: false, hasAny: false, receivableEntryId: null,
receivableReversalId: null, payableEntryId: null, payableReversalId: null,
missing: ['receivable', 'receivable_reversal', 'payable', 'payable_reversal'],
duplicates: [],
},
})
})
describe('gnubok_post_kontantmetod_cutoff', () => {
it('is a discoverable high-risk staged bookkeeping write with readiness preflight', () => {
expect(tool).toBeDefined()
expect(tool.catalogVisibility).toBe('search')
expect(tool.annotations).toMatchObject({ readOnlyHint: false, destructiveHint: true })
expect(TOOL_SCOPE_MAP.gnubok_post_kontantmetod_cutoff).toBe('bookkeeping:write')
expect(deriveToolMeta(tool)).toMatchObject({
requires_approval: true,
approve_tool: 'gnubok_approve_pending_operation',
preflight: 'gnubok_year_end_readiness',
})
})
it('stages the exact two cut-offs and two day-one reversals without posting', async () => {
const supabase = makeSupabase()
const result = (await tool.execute(
{ fiscal_period_id: 'fp-1' },
'company-1', 'user-1', supabase as never, { type: 'api_key' },
)) as { staged: boolean; risk_level: string; preview: { entries: Array<Record<string, unknown>> } }
expect(result.staged).toBe(true)
expect(result.risk_level).toBe('high')
expect(result.preview.entries).toHaveLength(4)
expect(result.preview.entries.map((entry) => entry.entry_date)).toEqual([
'2026-12-31', '2027-01-01', '2026-12-31', '2027-01-01',
])
expect(result.preview.entries[0]?.lines).toEqual(buildCutoffLines(collection.receivables, []).receivableLines)
expect(supabase.inserts).toHaveLength(1)
expect(supabase.inserts[0]).toMatchObject({
operation_type: 'post_kontantmetod_cutoff',
risk_level: 'high',
params: { fiscal_period_id: 'fp-1', next_fiscal_period_id: 'fp-2', collection },
})
})
it('refuses accrual companies, missing next periods, invalid VAT, duplicates, and empty previews', async () => {
const accrual = makeSupabase({ accounting_method: 'accrual', entity_type: 'aktiebolag' })
await expect(tool.execute(
{ fiscal_period_id: 'fp-1' }, 'company-1', 'user-1', accrual as never,
)).rejects.toThrow(/inte kontantmetoden/i)
vi.mocked(findNextPeriod).mockResolvedValueOnce(null)
await expect(tool.execute(
{ fiscal_period_id: 'fp-1' }, 'company-1', 'user-1', makeSupabase() as never,
)).rejects.toThrow(/nästa räkenskapsår/i)
vi.mocked(assessKontantmetodCutoff).mockResolvedValueOnce({
collection: { ...collection, unknownVatTreatment: ['F-9'] },
lines: buildCutoffLines([], []),
postings: { complete: false, hasAny: false, receivableEntryId: null, receivableReversalId: null, payableEntryId: null, payableReversalId: null, missing: [], duplicates: [] },
})
await expect(tool.execute(
{ fiscal_period_id: 'fp-1' }, 'company-1', 'user-1', makeSupabase() as never,
)).rejects.toThrow(/saknar momsinställning/i)
vi.mocked(assessKontantmetodCutoff).mockResolvedValueOnce({
collection,
lines: buildCutoffLines(collection.receivables, collection.payables),
postings: { complete: true, hasAny: true, receivableEntryId: 'je-1', receivableReversalId: 'je-2', payableEntryId: 'je-3', payableReversalId: 'je-4', missing: [], duplicates: [] },
})
await expect(tool.execute(
{ fiscal_period_id: 'fp-1' }, 'company-1', 'user-1', makeSupabase() as never,
)).rejects.toThrow(/redan bokförd/i)
vi.mocked(assessKontantmetodCutoff).mockResolvedValueOnce({
collection: { receivables: [], payables: [], unknownVatTreatment: [], strayVatOnZeroRate: [] },
lines: buildCutoffLines([], []),
postings: { complete: true, hasAny: false, receivableEntryId: null, receivableReversalId: null, payableEntryId: null, payableReversalId: null, missing: [], duplicates: [] },
})
await expect(tool.execute(
{ fiscal_period_id: 'fp-1' }, 'company-1', 'user-1', makeSupabase() as never,
)).rejects.toThrow(/Inga obetalda/i)
})
})
@@ -67,7 +67,7 @@ describe('gnubok_year_end_readiness: registration', () => {
)
// 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)
expect(actionable.length).toBe(8)
for (const kind of actionable) {
expect(tool.description, `blocker kind ${kind} missing from description`).toContain(kind)
}
+196 -3
View File
@@ -156,8 +156,15 @@ import {
validateVoucherForSupplierInvoiceLink,
} from '@/lib/invoices/supplier-voucher-matching'
import { findFiscalPeriod, reverseEntry, validateBalance } from '@/lib/bookkeeping/engine'
import { closePeriod, countUnbookedInPeriod, lockPeriod, resolvePeriodStatusForDate, type PeriodStatusForDate } from '@/lib/core/bookkeeping/period-service'
import { closePeriod, countUnbookedInPeriod, findNextPeriod, lockPeriod, resolvePeriodStatusForDate, type PeriodStatusForDate } from '@/lib/core/bookkeeping/period-service'
import { validateYearEndReadiness, previewYearEndClosing } from '@/lib/core/bookkeeping/year-end-service'
import {
assessKontantmetodCutoff,
hasIncompleteKontantmetodCutoffPair,
KONTANTMETOD_CUTOFF_DESCRIPTIONS,
nextDay,
reverseLines,
} from '@/lib/core/bookkeeping/kontantmetod-cutoff'
import { generateSIEExport } from '@/lib/reports/sie-export'
import { generateFullArchive, estimateArchiveSize } from '@/lib/reports/full-archive-export'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
@@ -1144,6 +1151,7 @@ const STAGED_OPERATION_SCHEMA = {
*/
const TOOL_PREFLIGHT_MAP: Record<string, string> = {
gnubok_run_year_end: 'gnubok_year_end_readiness',
gnubok_post_kontantmetod_cutoff: 'gnubok_year_end_readiness',
gnubok_vat_declaration_submit: 'gnubok_vat_declaration_validate',
gnubok_post_annual_depreciation: 'gnubok_propose_annual_depreciation',
gnubok_book_salary_run: 'gnubok_get_salary_run',
@@ -1761,6 +1769,8 @@ export const YEAR_END_BLOCKER_KIND: Record<YearEndBlockerCode, string> = {
TRIAL_BALANCE_UNBALANCED: 'trial_balance_unbalanced',
CONTINUITY_MISMATCH: 'opening_balance_continuity',
NEXT_PERIOD_HAS_IB: 'next_period_ib_posted',
KONTANTMETOD_CUTOFF_REQUIRED: 'kontantmetod_cutoff_required',
KONTANTMETOD_CUTOFF_CHECK_FAILED: 'kontantmetod_cutoff_required',
UNBOOKED_TRANSACTIONS: 'unbooked_transactions',
UNBOOKED_CHECK_FAILED: 'unbooked_transactions',
}
@@ -13132,7 +13142,7 @@ export const tools: McpTool[] = [
// there, the period either is closable or is not. Open items in foreign
// currency are warnings, never blockers, because executeYearEndClosing
// revalues them in step 2 (lib/core/bookkeeping/year-end-service.ts).
description: "Pre-flight for irreversible gnubok_run_year_end. Blockers: unbooked_transactions (most common), draft_entries, unexplained_voucher_gap, sequence_mismatch, trial_balance_unbalanced, opening_balance_continuity, next_period_ib_posted, period-state. FX = warning, never blocker.",
description: 'Year-end check. Blockers: kontantmetod_cutoff_required, unbooked_transactions (most common), draft_entries, unexplained_voucher_gap, sequence_mismatch, trial_balance_unbalanced, opening_balance_continuity, next_period_ib_posted, period-state. FX = warning, never blocker.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -13236,6 +13246,189 @@ export const tools: McpTool[] = [
},
},
{
name: 'gnubok_post_kontantmetod_cutoff',
title: 'Post Cash-Method Year-End Cut-Off',
description: 'Stage the exact year-end receivable/payable cut-off and next-period reversals required for kontantmetoden. Review all proposed lines, then approve with confirmed=true.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
fiscal_period_id: {
type: 'string',
description: 'UUID of the cash-method fiscal period being closed',
},
idempotency_key: {
type: 'string',
description: 'Optional UUID to dedupe retries of the same preview and staged operation',
},
},
required: ['fiscal_period_id'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
openWorldHint: false,
},
// Specialized cash-method year-end step. The year-end readiness blocker
// and year-end skill name it exactly, while search-only visibility avoids
// charging every MCP session for a schema most companies never need.
catalogVisibility: 'search',
async execute(args, companyId, userId, supabase, actor) {
const fiscalPeriodId = args.fiscal_period_id as string
if (!fiscalPeriodId) throw new Error('fiscal_period_id is required')
const [{ data: period }, { data: settings }] = await Promise.all([
supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end, is_closed, locked_at')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.maybeSingle(),
supabase
.from('company_settings')
.select('accounting_method, entity_type')
.eq('company_id', companyId)
.maybeSingle(),
])
if (!period) throw new Error('Fiscal period not found')
if (period.is_closed || period.locked_at) {
throw new Error('Räkenskapsperioden är stängd eller låst')
}
if (settings?.accounting_method !== 'cash') {
throw new Error('Företaget använder inte kontantmetoden')
}
const nextPeriod = await findNextPeriod(supabase, companyId, fiscalPeriodId)
if (!nextPeriod) {
throw new Error(
'Kontantmetodens bokslutsavgränsning kräver att nästa räkenskapsår är upplagt: vändningarna bokas första dagen på det nya året.',
)
}
if (nextPeriod.is_closed || nextPeriod.locked_at) {
throw new Error(
'Nästa räkenskapsår är stängt eller låst: vändningarna kan inte bokföras. Lås upp perioden och försök igen.',
)
}
const assessment = await assessKontantmetodCutoff(
supabase,
companyId,
period,
nextPeriod.id,
(settings.entity_type ?? 'aktiebolag') as EntityType,
)
if (assessment.collection.unknownVatTreatment.length > 0) {
throw new Error(
`${assessment.collection.unknownVatTreatment.length} fakturor saknar momsinställning: ` +
`${assessment.collection.unknownVatTreatment.slice(0, 10).join(', ')}. Komplettera dem och försök igen.`,
)
}
if (assessment.collection.strayVatOnZeroRate.length > 0) {
throw new Error(
`${assessment.collection.strayVatOnZeroRate.length} fakturor har moms trots en momsfri momsinställning: ` +
`${assessment.collection.strayVatOnZeroRate.slice(0, 10).join(', ')}. Rätta dem och försök igen.`,
)
}
if (
assessment.lines.receivableLines.length === 0 &&
assessment.lines.payableLines.length === 0
) {
throw new Error('Inga obetalda kund- eller leverantörsfakturor finns vid periodens slut')
}
if (
assessment.postings.complete ||
hasIncompleteKontantmetodCutoffPair(assessment.postings, assessment.lines)
) {
throw new Error(
'Kontantmetodens bokslutsavgränsning är redan bokförd eller delvis bokförd för perioden. Kontrollera verifikaten innan du försöker igen.',
)
}
const reversalDate = nextDay(period.period_end)
const entries = [
...(assessment.lines.receivableLines.length > 0 &&
!assessment.postings.receivableEntryId &&
!assessment.postings.receivableReversalId
? [
{
kind: 'receivable_cutoff',
fiscal_period_id: fiscalPeriodId,
entry_date: period.period_end,
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivable,
total: assessment.lines.receivableTotal,
lines: assessment.lines.receivableLines,
},
{
kind: 'receivable_reversal',
fiscal_period_id: nextPeriod.id,
entry_date: reversalDate,
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivableReversal,
total: assessment.lines.receivableTotal,
lines: reverseLines(assessment.lines.receivableLines),
},
]
: []),
...(assessment.lines.payableLines.length > 0 &&
!assessment.postings.payableEntryId &&
!assessment.postings.payableReversalId
? [
{
kind: 'payable_cutoff',
fiscal_period_id: fiscalPeriodId,
entry_date: period.period_end,
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.payable,
total: assessment.lines.payableTotal,
lines: assessment.lines.payableLines,
},
{
kind: 'payable_reversal',
fiscal_period_id: nextPeriod.id,
entry_date: reversalDate,
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.payableReversal,
total: assessment.lines.payableTotal,
lines: reverseLines(assessment.lines.payableLines),
},
]
: []),
]
return stagePendingOperation(
supabase,
companyId,
userId,
'post_kontantmetod_cutoff',
`Kontantmetodens bokslutsavgränsning: ${period.name}`,
{
fiscal_period_id: fiscalPeriodId,
next_fiscal_period_id: nextPeriod.id,
collection: assessment.collection,
},
{
fiscal_period_id: fiscalPeriodId,
next_fiscal_period_id: nextPeriod.id,
receivable_count: assessment.collection.receivables.length,
payable_count: assessment.collection.payables.length,
entries,
},
actor,
{
description: 'Re-run year-end readiness after the cut-off and all reversals are posted.',
tool: 'gnubok_year_end_readiness',
args: { fiscal_period_id: fiscalPeriodId },
},
{
idempotencyKey:
typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined,
dateForPeriodCheck: period.period_end,
},
)
},
},
{
name: 'gnubok_run_year_end',
title: 'Run Year-End Closing (Bokslut)',
@@ -16800,7 +16993,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
'• VAT: gnubok_get_vat_report(period_type, year, period). Ruta49 = VAT to pay (positive) or refund (negative). Pass render_ui=true to open the momsdeklaration review widget (claude.ai / Desktop). gnubok_vat_close_check reports filing-readiness blockers.',
'• Reporting: gnubok_get_trial_balance / _income_statement / _balance_sheet / _kpi_report / _ar_ledger / _supplier_ledger: all default to the most recent fiscal period. For account roll-ups use gnubok_get_general_ledger; for ad-hoc line queries (free-text, amount/date/source filters) use gnubok_query_journal.',
'• Interactive review UIs (claude.ai / Claude Desktop only): gnubok_get_vat_report(render_ui=true) renders the VAT widget, gnubok_receipt_matcher opens the receipt↔transaction matcher, and gnubok_list_pending_operations(render_ui=true) opens the approval queue where the user approves/rejects with a click. All also return structured data; other clients ignore the UI and use the data.',
'• Year-end: gnubok_lock_period → gnubok_run_year_end → gnubok_set_opening_balances → gnubok_close_period. Each stages for human approval; closing is irreversible per BFL.',
'• Year-end: run gnubok_year_end_readiness first. For kontantmetoden, resolve kontantmetod_cutoff_required with the searchable gnubok_post_kontantmetod_cutoff tool. Then gnubok_run_year_end → gnubok_set_opening_balances → gnubok_close_period. Each write stages for human approval; closing is irreversible per BFL.',
'• Payroll: gnubok_create_salary_run → gnubok_calculate_salary_run → gnubok_book_salary_run → gnubok_generate_agi.',
'• Reviewing & approving staged operations: gnubok_list_pending_operations shows the queue. When the user explicitly authorises a specific operation_id in chat, call gnubok_approve_pending_operation to commit. Use gnubok_reject_pending_operation to discard.',
'',
@@ -28,15 +28,23 @@ Before running year-end, post any year-end adjusting entries via the web app:
These are not staged via MCP today: direct in web UI. The skill is to remind the user.
### Step 2: Currency revaluation (if multi-currency)
### Step 2: Cash-method cut-off (kontantmetoden only)
For a company using kontantmetoden, first run
\`gnubok_post_kontantmetod_cutoff({ fiscal_period_id })\`.
It stages the exact customer-receivable and supplier-payable entries dated on the fiscal year end, plus their reversals on day one of the next period. Review every proposed line and approve with \`confirmed=true\`. The next fiscal period must already exist and be open. Re-run \`gnubok_year_end_readiness\` after approval: BFL 5 kap 2 § makes this a blocker, not an optional reminder.
### Step 3: Currency revaluation (if multi-currency)
If the company has open foreign-currency receivables/payables (1510/2440 in EUR/USD/etc.), revalue to closing-date FX rate via \`gnubok_run_currency_revaluation({ fiscal_period_id, closing_date })\`. Posts to **3960** (kursvinster) and **7960** (kursförluster). One revaluation per period.
### Step 3: Lock the period
### Step 4: Lock the period
\`gnubok_lock_period(fiscal_period_id)\`. Required before year-end. Refuses if business transactions are unbooked.
### Step 4: Run year-end
### Step 5: Run year-end
\`gnubok_run_year_end(fiscal_period_id)\`: stages a high-risk operation. After approval:
@@ -44,11 +52,11 @@ If the company has open foreign-currency receivables/payables (1510/2440 in EUR/
- Period flagged \`is_year_end_complete\`
- Next period created automatically
### Step 5: Set opening balances
### Step 6: Set opening balances
\`gnubok_set_opening_balances({ closed_period_id, next_period_id })\`. Copies class 1-2 closing balances into the next period as opening balances. Stage → approve.
### Step 6: Close (final, irreversible)
### Step 7: Close (final, irreversible)
\`gnubok_close_period(fiscal_period_id)\`. Once approved, the period is sealed forever. **No more entries possible, not even via storno.**
@@ -85,6 +93,7 @@ These compute with \`gnubok_get_kpi_report\` for inputs but the actual tax JE is
## Tools
- \`gnubok_lock_period\`: pre-flight before year-end
- \`gnubok_post_kontantmetod_cutoff\`: stage the mandatory cash-method cut-off and reversals
- \`gnubok_run_year_end\`: zero result accounts
- \`gnubok_set_opening_balances\`: seed next period
- \`gnubok_run_currency_revaluation\`: FX revaluation