Add/customer mcp (#1055)

* feat(mcp): kontoplan account tools + verifikat notes exposure

Two gaps reported by an MCP-driven user: no account management in the
API, and verifikat notes invisible to agents (they exist in the product
but MCP could neither read nor write them).

- add staged gnubok_create_account / gnubok_update_account (BAS 2026
  prefill for catalog numbers; rename/VAT-default/SRU/activate via
  update; both LOW risk reference data)
- add staged gnubok_set_voucher_note (notes-only annotation, legal on
  posted entries per the 20260608120000 trigger carve-out) and return
  entry_notes from gnubok_query_journal
- new pending_operations types create_account / update_account /
  set_voucher_note (CHECK migration + validate companion, applied to
  staging)
- tools/list payload ceiling 54K -> 56K (documented; wire contract,
  descriptions trimmed first)

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

* fix(skatteverket): unstick BankID connect flow and stale connection views

- respond to the OAuth callback immediately and run the post-connect
  refresh after the response (next/server after()): users no longer
  stare at Skatteverket's consumed consent page for up to 40s
- open the consent flow in a full tab instead of a 600x750 popup that
  hid the approve button below the fold
- disable connect buttons while the OAuth tab is open (parallel flows
  overwrote oauth_state + the PKCE verifier) and recover via a
  closed-tab watcher plus a delayed status refetch
- persist MISSING_SCOPE token health from the post-connect sync and
  show an actionable "approve all permissions" notice
- refetch connection state on tab visibility (settings connect panel,
  enable-banking panel, /skattekonto) so a connect completed in another
  tab or after a mobile app-switch shows up without a manual reload;
  fix /skattekonto never clearing its not-connected state

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

* feat(article-form): add article number field with validation to ArticleForm

* feat(account): enforce account type consistency with BAS class and add validation

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-17 14:13:53 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent bfd5b42eb1
commit 0e9cca2750
28 changed files with 1944 additions and 101 deletions
@@ -62,6 +62,34 @@ export default function BankingSettingsPanel() {
}
}, [])
// Latest-ref so the visibility listener below (subscribed once) always
// calls the current render's fetchConnections, which closes over company
// context that may resolve after mount.
const fetchConnectionsRef = useRef(fetchConnections)
useEffect(() => {
fetchConnectionsRef.current = fetchConnections
})
// Safety net for completion signals that never reach this tab: a mobile
// BankID app-switch can land the bank's redirect in a different browser
// tab, the user can close the finalize page before its redirect, and a
// bfcache-restored page shows a pre-connection snapshot. Refetch when the
// tab regains visibility (background refresh, no spinner: fetchConnections
// only blanks the panel on first load), throttled so rapid tab toggling
// doesn't hammer the API.
const lastVisibilityFetchRef = useRef(0)
useEffect(() => {
function onVisible() {
if (document.visibilityState !== 'visible') return
const now = Date.now()
if (now - lastVisibilityFetchRef.current < 5_000) return
lastVisibilityFetchRef.current = now
void fetchConnectionsRef.current()
}
document.addEventListener('visibilitychange', onVisible)
return () => document.removeEventListener('visibilitychange', onVisible)
}, [])
// Auto-open the picker when the user lands here from the OAuth callback
// (URL: /settings/banking?select_accounts=<id>). The query param is stripped
// afterwards so a refresh doesn't keep reopening it.
@@ -0,0 +1,234 @@
/**
* Unit tests for the staged kontoplan tools: gnubok_create_account and
* gnubok_update_account. Covers registration/scope/risk-tier wiring, the
* BAS 2026 prefill (resolve-don't-guess), the duplicate/inactive pre-flight
* gates, and dry-run staging behaviour. Executor-side coverage
* (commitCreateAccount / commitUpdateAccount) lives in
* lib/pending-operations/__tests__/account-and-note-executors.test.ts.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { tools } from '../server'
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
import { OPERATION_RISK_TIERS } from '@/lib/pending-operations/risk-tiers'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
const createAccount = tools.find((t) => t.name === 'gnubok_create_account')!
const updateAccount = tools.find((t) => t.name === 'gnubok_update_account')!
/**
* A 4-digit number guaranteed absent from the BAS 2026 catalog, in classes
* 4-7 so the fixture's account_type 'expense' passes the class/type
* consistency guard.
*/
function findNonBasNumber(): string {
for (let n = 4000; n <= 7999; n++) {
const candidate = String(n)
if (!getBASReference(candidate)) return candidate
}
throw new Error('BAS catalog unexpectedly covers every 4-digit expense number')
}
const NON_BAS_NUMBER = findNonBasNumber()
const noopSupabase = { from: vi.fn() } as never
beforeEach(() => {
vi.clearAllMocks()
})
describe('kontoplan tools: registration', () => {
it('both tools exist, stage, and declare strict schemas', () => {
for (const tool of [createAccount, updateAccount]) {
expect(tool).toBeDefined()
expect((tool.inputSchema as { additionalProperties?: boolean }).additionalProperties).toBe(false)
const out = tool.outputSchema as { properties?: Record<string, unknown>; required?: string[] }
expect(out?.properties?.staged).toBeDefined()
expect(out?.required).toContain('staged')
expect(tool.description).toMatch(/stag(e|es|ing)/i)
expect(tool.annotations.readOnlyHint).toBe(false)
expect(tool.annotations.destructiveHint).toBe(false)
}
})
it('only requires account_number', () => {
expect((createAccount.inputSchema as { required?: string[] }).required).toEqual(['account_number'])
expect((updateAccount.inputSchema as { required?: string[] }).required).toEqual(['account_number'])
})
it('is mapped to bookkeeping:write scope and low risk tier', () => {
expect(TOOL_SCOPE_MAP.gnubok_create_account).toBe('bookkeeping:write')
expect(TOOL_SCOPE_MAP.gnubok_update_account).toBe('bookkeeping:write')
expect(OPERATION_RISK_TIERS.create_account).toBe('low')
expect(OPERATION_RISK_TIERS.update_account).toBe('low')
})
})
describe('gnubok_create_account: validation gates', () => {
it('rejects a non-4-digit account number before any DB call', async () => {
await expect(
createAccount.execute({ account_number: '193' }, 'company-1', 'user-1', noopSupabase),
).rejects.toThrow(/4 digits/)
await expect(
createAccount.execute({ account_number: '19300' }, 'company-1', 'user-1', noopSupabase),
).rejects.toThrow(/4 digits/)
})
it('rejects when the account already exists and is active', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { account_number: '5410', account_name: 'Förbrukningsinventarier', is_active: true } })
await expect(
createAccount.execute({ account_number: '5410' }, 'company-1', 'user-1', supabase as never),
).rejects.toThrow(/finns redan/)
})
it('points to gnubok_update_account when the account exists but is inactive', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { account_number: '5410', account_name: 'Förbrukningsinventarier', is_active: false } })
await expect(
createAccount.execute({ account_number: '5410' }, 'company-1', 'user-1', supabase as never),
).rejects.toThrow(/inaktivt.*is_active=true/s)
})
it('rejects a non-BAS number without name/type/balance', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null }) // no existing row
await expect(
createAccount.execute({ account_number: NON_BAS_NUMBER }, 'company-1', 'user-1', supabase as never),
).rejects.toThrow(/not in the BAS 2026 catalog/)
})
it('rejects a percent-style default_vat_rate (must be a fraction)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null })
await expect(
createAccount.execute(
{ account_number: '5410', default_vat_rate: 25 },
'company-1', 'user-1', supabase as never,
),
).rejects.toThrow(/fraction, not percent/)
})
it('rejects an account_type inconsistent with the BAS class digit', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null }) // no existing row
await expect(
createAccount.execute(
{ account_number: '2999', account_name: 'Fel', account_type: 'expense', normal_balance: 'debit' },
'company-1', 'user-1', supabase as never,
),
).rejects.toThrow(/BAS class 2/)
})
it('exposes untaxed_reserves in the input schema enum (21xx round-trip)', () => {
const props = (createAccount.inputSchema as { properties: Record<string, { enum?: string[] }> }).properties
expect(props.account_type.enum).toContain('untaxed_reserves')
})
})
describe('gnubok_create_account: staging behaviour (dry_run)', () => {
it('prefills name/type/balance/SRU from the BAS catalog', async () => {
const ref = getBASReference('5410')!
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null }) // no existing row
const result = (await createAccount.execute(
{ account_number: '5410', dry_run: true },
'company-1', 'user-1', supabase as never,
)) as { dry_run?: boolean; preview: Record<string, unknown> }
expect(result.dry_run).toBe(true)
expect(result.preview).toMatchObject({
account_number: '5410',
account_name: ref.account_name,
account_type: ref.account_type,
normal_balance: ref.normal_balance,
plan_type: 'full_bas',
source: 'bas_2026',
})
})
it('explicit args win over the BAS prefill', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null })
const result = (await createAccount.execute(
{ account_number: '5410', account_name: 'Verktyg och maskiner', dry_run: true },
'company-1', 'user-1', supabase as never,
)) as { preview: Record<string, unknown> }
expect(result.preview.account_name).toBe('Verktyg och maskiner')
expect(result.preview.source).toBe('bas_2026')
})
it('stages a fully-specified custom account as plan_type k1 / source custom', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null })
const result = (await createAccount.execute(
{
account_number: NON_BAS_NUMBER,
account_name: 'Eget specialkonto',
account_type: 'expense',
normal_balance: 'debit',
dry_run: true,
},
'company-1', 'user-1', supabase as never,
)) as { preview: Record<string, unknown> }
expect(result.preview).toMatchObject({
account_number: NON_BAS_NUMBER,
account_name: 'Eget specialkonto',
account_type: 'expense',
normal_balance: 'debit',
plan_type: 'k1',
source: 'custom',
})
})
})
describe('gnubok_update_account', () => {
it('rejects a non-4-digit account number before any DB call', async () => {
await expect(
updateAccount.execute({ account_number: 'abcd' }, 'company-1', 'user-1', noopSupabase),
).rejects.toThrow(/4 digits/)
})
it('points to gnubok_create_account when the account does not exist', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null })
await expect(
updateAccount.execute(
{ account_number: '5410', account_name: 'Nytt namn' },
'company-1', 'user-1', supabase as never,
),
).rejects.toThrow(/finns inte.*gnubok_create_account/s)
})
it('rejects a call with no fields to change', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { account_number: '5410', account_name: 'Förbrukningsinventarier', is_active: true } })
await expect(
updateAccount.execute({ account_number: '5410' }, 'company-1', 'user-1', supabase as never),
).rejects.toThrow(/Nothing to update/)
})
it('dry-run preview carries current values and the requested changes', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: {
account_number: '5410',
account_name: 'Förbrukningsinventarier',
description: null,
default_vat_code: null,
default_vat_rate: null,
sru_code: '7321', // 5410's catalog value (lib/bookkeeping/bas-data)
is_active: true,
},
})
const result = (await updateAccount.execute(
{ account_number: '5410', account_name: 'Verktyg', is_active: false, dry_run: true },
'company-1', 'user-1', supabase as never,
)) as { dry_run?: boolean; preview: { current: Record<string, unknown>; changes: Record<string, unknown> } }
expect(result.dry_run).toBe(true)
expect(result.preview.current.account_name).toBe('Förbrukningsinventarier')
expect(result.preview.changes).toEqual({ account_name: 'Verktyg', is_active: false })
})
})
@@ -116,9 +116,16 @@ describe('tools/list payload size guard', () => {
// the client can target another authorized company without shared
// mutable connection state. The repeated property is intentionally
// minimal; gnubok_list_companies and initialize instructions explain it.
// * 54K → 56K with kontoplan management + verifikat notes (MCP parity
// requested by an MCP-driven user): staged gnubok_create_account /
// gnubok_update_account (kontoplan reference data, BAS 2026 prefill)
// + gnubok_set_voucher_note (notes-only annotation, trigger-guarded),
// each inlining STAGED_OPERATION_SCHEMA + _meta + company_id routing.
// Descriptions and property prose trimmed first; the remainder is
// wire contract.
// Long-term answer to growth is leaning harder on gnubok_search_tools: if this
// fires again, prefer trimming descriptions or making a tool opt-in via search
// before bumping further.
expect(approxTokens).toBeLessThan(54_000)
expect(approxTokens).toBeLessThan(56_000)
})
})
@@ -128,6 +128,7 @@ function makeLineRow(opts: {
credit_amount?: number
line_description?: string | null
entry_description?: string
entry_notes?: string | null
voucher_number?: number
entry_date?: string
}) {
@@ -147,12 +148,34 @@ function makeLineRow(opts: {
voucher_series: 'A',
entry_date: opts.entry_date ?? '2026-03-15',
description: opts.entry_description ?? '',
notes: opts.entry_notes ?? null,
source_type: 'bank_transaction',
status: 'posted',
},
}
}
describe('gnubok_query_journal: entry notes (verifikat-anteckningar)', () => {
it('surfaces journal_entries.notes as entry_notes on every returned line', async () => {
const tool = tools.find((t) => t.name === 'gnubok_query_journal')!
const rows = [
makeLineRow({ id: 'l1', entry_notes: 'Avser Q1-hyran, se mail 12/3' }),
makeLineRow({ id: 'l2' }),
]
const supabase = makeChainMock(rows, rows.length)
const result = (await tool.execute(
{ accounts: ['4010'] },
'company-1', 'user-1', supabase,
)) as { lines: Array<{ line_id: string; entry_notes: string | null }> }
expect(result.lines.find((l) => l.line_id === 'l1')?.entry_notes).toBe(
'Avser Q1-hyran, se mail 12/3',
)
expect(result.lines.find((l) => l.line_id === 'l2')?.entry_notes).toBeNull()
})
})
describe('gnubok_query_journal: execute', () => {
it('applies amount_min filter and computes totals on the filtered set', async () => {
const tool = tools.find((t) => t.name === 'gnubok_query_journal')!
@@ -0,0 +1,134 @@
/**
* Unit tests for gnubok_set_voucher_note: registration/scope/risk wiring,
* input validation, the not-found pre-flight, note normalisation ('' → null),
* and dry-run staging with dateForPeriodCheck threading. Executor-side
* coverage (commitSetVoucherNote) lives in
* lib/pending-operations/__tests__/account-and-note-executors.test.ts.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { tools } from '../server'
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
import { OPERATION_RISK_TIERS } from '@/lib/pending-operations/risk-tiers'
const tool = tools.find((t) => t.name === 'gnubok_set_voucher_note')!
const ENTRY_ROW = {
id: 'je-1',
voucher_series: 'A',
voucher_number: 42,
entry_date: '2026-03-15',
description: 'Hyra mars',
status: 'posted',
notes: 'Gammal anteckning',
}
const noopSupabase = { from: vi.fn() } as never
beforeEach(() => {
vi.clearAllMocks()
})
describe('gnubok_set_voucher_note: registration', () => {
it('is registered, stages, and declares a strict schema', () => {
expect(tool).toBeDefined()
expect((tool.inputSchema as { additionalProperties?: boolean }).additionalProperties).toBe(false)
const out = tool.outputSchema as { properties?: Record<string, unknown>; required?: string[] }
expect(out?.properties?.staged).toBeDefined()
expect(out?.required).toContain('staged')
expect(tool.description).toMatch(/stag(e|es|ing)/i)
})
it('requires journal_entry_id AND notes (explicit null to clear, never implicit)', () => {
expect((tool.inputSchema as { required?: string[] }).required).toEqual(['journal_entry_id', 'notes'])
})
it('is mapped to bookkeeping:write scope and low risk tier', () => {
expect(TOOL_SCOPE_MAP.gnubok_set_voucher_note).toBe('bookkeeping:write')
expect(OPERATION_RISK_TIERS.set_voucher_note).toBe('low')
})
})
describe('gnubok_set_voucher_note: validation gates', () => {
it('rejects a missing journal_entry_id before any DB call', async () => {
await expect(
tool.execute({ journal_entry_id: '', notes: 'x' }, 'company-1', 'user-1', noopSupabase),
).rejects.toThrow(/journal_entry_id/)
})
it('rejects non-string non-null notes before any DB call', async () => {
await expect(
tool.execute({ journal_entry_id: 'je-1', notes: 42 }, 'company-1', 'user-1', noopSupabase),
).rejects.toThrow(/string.*null/s)
})
it('rejects notes longer than 2000 chars before any DB call', async () => {
await expect(
tool.execute(
{ journal_entry_id: 'je-1', notes: 'x'.repeat(2001) },
'company-1', 'user-1', noopSupabase,
),
).rejects.toThrow(/2000/)
})
it('rejects when the entry does not exist in this company', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null })
await expect(
tool.execute({ journal_entry_id: 'je-x', notes: 'x' }, 'company-1', 'user-1', supabase as never),
).rejects.toThrow(/hittades inte/)
})
})
describe('gnubok_set_voucher_note: staging behaviour (dry_run)', () => {
it('previews old and new note with the voucher label', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: ENTRY_ROW })
const result = (await tool.execute(
{ journal_entry_id: 'je-1', notes: 'Avser Q1-hyran, se mail 12/3', dry_run: true },
'company-1', 'user-1', supabase as never,
)) as { dry_run?: boolean; preview: Record<string, unknown> }
expect(result.dry_run).toBe(true)
expect(result.preview).toMatchObject({
journal_entry_id: 'je-1',
voucher: 'A42',
entry_status: 'posted',
old_notes: 'Gammal anteckning',
new_notes: 'Avser Q1-hyran, se mail 12/3',
})
})
it('normalises a whitespace-only note to null (clear)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: ENTRY_ROW })
const result = (await tool.execute(
{ journal_entry_id: 'je-1', notes: ' ', dry_run: true },
'company-1', 'user-1', supabase as never,
)) as { preview: Record<string, unknown> }
expect(result.preview.new_notes).toBeNull()
})
it('accepts explicit null to clear the note', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: ENTRY_ROW })
const result = (await tool.execute(
{ journal_entry_id: 'je-1', notes: null, dry_run: true },
'company-1', 'user-1', supabase as never,
)) as { preview: Record<string, unknown> }
expect(result.preview.new_notes).toBeNull()
})
it('labels a draft (no voucher number yet) as utkast', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { ...ENTRY_ROW, voucher_number: null, status: 'draft' } })
const result = (await tool.execute(
{ journal_entry_id: 'je-1', notes: 'Utkast-anteckning', dry_run: true },
'company-1', 'user-1', supabase as never,
)) as { preview: Record<string, unknown> }
expect(result.preview.voucher).toBe('utkast')
})
})
+271 -1
View File
@@ -39,6 +39,8 @@ import type { SkillTier } from './skills'
import { getRiskLevel } from '@/lib/pending-operations/risk-tiers'
import { normalizeVatRateToDecimal } from '@/lib/vat/supplier-invoice-line-checks'
import { CreateSupplierParamsSchema } from '@/lib/pending-operations/schemas/create-supplier'
import { accountClassTypeConflict } from '@/lib/pending-operations/schemas/account'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import { CreateDimensionValueParamsSchema } from '@/lib/pending-operations/schemas/dimension-value'
import { RetagLineDimensionsParamsSchema, RETAG_MAX_LINES } from '@/lib/pending-operations/schemas/retag-line-dimensions'
import {
@@ -4947,6 +4949,196 @@ export const tools: McpTool[] = [
},
},
{
name: 'gnubok_create_account',
title: 'Create Account (Kontoplan)',
description: 'Stage a new kontoplan account. BAS 2026 numbers prefill name/type/SRU (overrides win); custom numbers need account_name, account_type, normal_balance. Inactive existing account? Use gnubok_update_account instead.',
outputSchema: STAGED_OPERATION_SCHEMA,
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
account_number: { type: 'string', description: '4-digit number, e.g. "5410".' },
account_name: { type: 'string', description: 'Optional for BAS numbers (prefilled).' },
account_type: {
type: 'string',
enum: ['asset', 'equity', 'liability', 'revenue', 'expense', 'untaxed_reserves'],
description: 'Required for non-BAS numbers. untaxed_reserves only for 21xx (obeskattade reserver).',
},
normal_balance: {
type: 'string',
enum: ['debit', 'credit'],
description: 'Required for non-BAS numbers.',
},
description: { type: 'string' },
default_vat_code: { type: 'string' },
default_vat_rate: { type: 'number', enum: [0, 0.06, 0.12, 0.25], description: 'Fraction (0.25 = 25%). Livsmedel: 0.06 from 2026-04-01 (temporary cut from 0.12, reverts 2027-12-31).' },
sru_code: { type: 'string', description: 'Prefilled for BAS numbers.' },
dry_run: { type: 'boolean', description: 'Validate and preview without staging.' },
idempotency_key: { type: 'string', description: 'Per-operation UUID for safe retries (24h TTL).' },
},
required: ['account_number'],
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase, actor) {
const accountNumber = String(args.account_number ?? '').trim()
if (!/^\d{4}$/.test(accountNumber)) {
throw new Error('account_number must be exactly 4 digits, e.g. "5410".')
}
// Fail fast on numbers already in this company's chart so the approver
// is never shown a create that would 409 at commit time.
const { data: existing, error: existingErr } = await supabase
.from('chart_of_accounts')
.select('account_number, account_name, is_active')
.eq('company_id', companyId)
.eq('account_number', accountNumber)
.maybeSingle()
if (existingErr) throw new Error(`Database error: ${existingErr.message}`)
if (existing) {
throw new Error(
existing.is_active
? `Konto ${accountNumber} (${existing.account_name}) finns redan i kontoplanen. Ändra det med gnubok_update_account.`
: `Konto ${accountNumber} (${existing.account_name}) finns men är inaktivt. Aktivera det med gnubok_update_account (is_active=true).`,
)
}
// Resolve-don't-guess: BAS 2026 catalog fills the gaps; explicit args win.
const ref = getBASReference(accountNumber)
const name = String(args.account_name ?? '').trim() || ref?.account_name
const accountType = (args.account_type as string | undefined) ?? ref?.account_type
const normalBalance = (args.normal_balance as string | undefined) ?? ref?.normal_balance
if (!name || !accountType || !normalBalance) {
throw new Error(
`${accountNumber} is not in the BAS 2026 catalog: account_name, account_type and normal_balance are required for custom accounts.`,
)
}
// Runtime guard (hosts don't always enforce inputSchema enums).
if (!['asset', 'equity', 'liability', 'revenue', 'expense', 'untaxed_reserves'].includes(accountType)) {
throw new Error('account_type must be one of: asset, equity, liability, revenue, expense, untaxed_reserves')
}
if (!['debit', 'credit'].includes(normalBalance)) {
throw new Error('normal_balance must be debit or credit')
}
// Fail fast on a class/type contradiction (e.g. 2999 + expense): the
// commit executor derives account_class from the first digit, so an
// inconsistent pair would misclassify balance sheet vs income statement.
const classConflict = accountClassTypeConflict(accountNumber, accountType)
if (classConflict) throw new Error(classConflict)
const vatRate = args.default_vat_rate as number | undefined
if (vatRate !== undefined && ![0, 0.06, 0.12, 0.25].includes(vatRate)) {
throw new Error('default_vat_rate must be one of 0, 0.06, 0.12, 0.25 (fraction, not percent)')
}
const params: Record<string, unknown> = {
account_number: accountNumber,
account_name: name,
account_type: accountType,
normal_balance: normalBalance,
plan_type: ref ? 'full_bas' : 'k1',
description: String(args.description ?? '').trim() || ref?.description || undefined,
default_vat_code: String(args.default_vat_code ?? '').trim() || undefined,
default_vat_rate: vatRate,
sru_code: String(args.sru_code ?? '').trim() || ref?.sru_code || undefined,
}
return stagePendingOperation(supabase, companyId, userId, 'create_account',
`Nytt konto: ${accountNumber} ${name}`,
params,
{ ...params, source: ref ? 'bas_2026' : 'custom' },
actor,
{
description: 'Once approved, the account is active and can carry voucher lines via gnubok_create_voucher or gnubok_categorize_transaction.',
tool: 'gnubok_list_accounts',
},
{
dryRun: Boolean(args.dry_run),
idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined,
}
)
},
},
{
name: 'gnubok_update_account',
title: 'Update Account (Kontoplan)',
description: 'Stage an edit to a kontoplan account: rename, description, default VAT, SRU code, or activate/deactivate via is_active. Stages for approval. Find accounts with gnubok_list_accounts.',
outputSchema: STAGED_OPERATION_SCHEMA,
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
account_number: { type: 'string', description: '4-digit number of the account to update.' },
account_name: { type: 'string' },
description: { type: 'string' },
default_vat_code: { type: 'string' },
default_vat_rate: { type: 'number', enum: [0, 0.06, 0.12, 0.25], description: 'Default VAT rate as a fraction (0.25 = 25%). Livsmedel: 0.06 from 2026-04-01 (temporary cut from 0.12, reverts 2027-12-31).' },
sru_code: { type: 'string' },
is_active: { type: 'boolean', description: 'false deactivates (hides from pickers, keeps history); true (re)activates.' },
dry_run: { type: 'boolean' },
idempotency_key: { type: 'string' },
},
required: ['account_number'],
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase, actor) {
const accountNumber = String(args.account_number ?? '').trim()
if (!/^\d{4}$/.test(accountNumber)) {
throw new Error('account_number must be exactly 4 digits, e.g. "5410".')
}
const { data: current, error: fetchErr } = await supabase
.from('chart_of_accounts')
.select('account_number, account_name, description, default_vat_code, default_vat_rate, sru_code, is_active')
.eq('company_id', companyId)
.eq('account_number', accountNumber)
.maybeSingle()
if (fetchErr) throw new Error(`Database error: ${fetchErr.message}`)
if (!current) {
throw new Error(`Konto ${accountNumber} finns inte i kontoplanen. Skapa det med gnubok_create_account.`)
}
const vatRate = args.default_vat_rate as number | undefined
if (vatRate !== undefined && ![0, 0.06, 0.12, 0.25].includes(vatRate)) {
throw new Error('default_vat_rate must be one of 0, 0.06, 0.12, 0.25 (fraction, not percent)')
}
const params: Record<string, unknown> = { account_number: accountNumber }
const changes: Record<string, unknown> = {}
for (const key of ['account_name', 'description', 'default_vat_code', 'default_vat_rate', 'sru_code', 'is_active']) {
if (args[key] !== undefined) {
params[key] = args[key]
changes[key] = args[key]
}
}
if (Object.keys(changes).length === 0) {
throw new Error('Nothing to update: pass at least one of account_name, description, default_vat_code, default_vat_rate, sru_code, is_active.')
}
return stagePendingOperation(supabase, companyId, userId, 'update_account',
`Uppdatera konto ${accountNumber} ${current.account_name}`,
params,
{ account_number: accountNumber, current, changes },
actor,
undefined,
{
dryRun: Boolean(args.dry_run),
idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined,
}
)
},
},
// ── Dimensions (kostnadsställe/projekt) ──────────────────────
{
@@ -5823,7 +6015,7 @@ export const tools: McpTool[] = [
// The dimensions jsonb only rides along when a group needs it: it is
// the widest column on the line and the aggregate pass fetches ALL rows.
const dimsSelect = groupByDimension ? ', dimensions' : ''
const DISPLAY_SELECT = `id, account_number, debit_amount, credit_amount, currency, line_description, project, cost_center${dimsSelect}, sort_order, journal_entries!inner(id, voucher_number, voucher_series, entry_date, description, source_type, status, company_id)`
const DISPLAY_SELECT = `id, account_number, debit_amount, credit_amount, currency, line_description, project, cost_center${dimsSelect}, sort_order, journal_entries!inner(id, voucher_number, voucher_series, entry_date, description, notes, source_type, status, company_id)`
// Lean projection for the full-match aggregate pass: only what totals
// and group buckets need. journal_entries stays embedded (!inner)
// because the entry-level filters bind to it.
@@ -5892,6 +6084,7 @@ export const tools: McpTool[] = [
voucher_series: string
entry_date: string
description: string
notes: string | null
source_type: string
status: string
}
@@ -6073,6 +6266,7 @@ export const tools: McpTool[] = [
voucher_number: r.journal_entries.voucher_number,
entry_date: r.journal_entries.entry_date,
entry_description: r.journal_entries.description,
entry_notes: r.journal_entries.notes ?? null,
source_type: r.journal_entries.source_type,
status: r.journal_entries.status,
account_number: r.account_number,
@@ -10881,6 +11075,82 @@ export const tools: McpTool[] = [
},
},
{
name: 'gnubok_set_voucher_note',
title: 'Set Voucher Note (Anteckning)',
description: 'Stage setting, replacing or clearing the internal note (anteckning) on a verifikat. Notes are annotation metadata, editable even on posted entries: bookkeeping fields stay immutable. Read them via gnubok_query_journal (entry_notes).',
outputSchema: STAGED_OPERATION_SCHEMA,
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
journal_entry_id: { type: 'string', description: 'Verifikat UUID (find via gnubok_query_journal).' },
notes: {
type: ['string', 'null'],
description: 'New note (max 2000 chars), replaces the old one; null or empty clears.',
},
dry_run: { type: 'boolean', description: 'Validate and preview without staging.' },
idempotency_key: { type: 'string', description: 'Per-operation UUID for safe retries (24h TTL).' },
},
required: ['journal_entry_id', 'notes'],
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase, actor) {
const journalEntryId = String(args.journal_entry_id ?? '').trim()
if (!journalEntryId) throw new Error('journal_entry_id is required')
if (args.notes !== null && typeof args.notes !== 'string') {
throw new Error('notes must be a string (max 2000 chars) or null to clear the note')
}
// Whitespace-only → null so the column never stores visually-empty
// annotations (same normalisation as the commit-boundary schema).
const notes = typeof args.notes === 'string' && args.notes.trim() !== '' ? args.notes : null
if (notes !== null && notes.length > 2000) {
throw new Error('notes must be 2000 characters or shorter')
}
const { data: entry, error: fetchErr } = await supabase
.from('journal_entries')
.select('id, voucher_series, voucher_number, entry_date, description, status, notes')
.eq('id', journalEntryId)
.eq('company_id', companyId)
.maybeSingle()
if (fetchErr) throw new Error(`Database error: ${fetchErr.message}`)
if (!entry) throw new Error('Verifikationen hittades inte.')
const voucherLabel = entry.voucher_number
? `${entry.voucher_series ?? ''}${entry.voucher_number}`
: 'utkast'
return stagePendingOperation(supabase, companyId, userId, 'set_voucher_note',
notes === null
? `Rensa anteckning på verifikat ${voucherLabel}`
: `Anteckning på verifikat ${voucherLabel}`,
{ journal_entry_id: journalEntryId, notes },
{
journal_entry_id: journalEntryId,
voucher: voucherLabel,
entry_description: entry.description,
entry_status: entry.status,
old_notes: entry.notes ?? null,
new_notes: notes,
},
actor,
undefined,
{
dryRun: Boolean(args.dry_run),
idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined,
dateForPeriodCheck: entry.entry_date,
}
)
},
},
{
name: 'gnubok_explain_voucher_gap',
title: 'Explain Voucher Gap',
@@ -0,0 +1,191 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from 'vitest'
// after() must be observable: the callback hands it the eager refresh
// promise so the serverless function stays alive past the response.
vi.mock('next/server', async (importOriginal) => {
const actual = await importOriginal<typeof import('next/server')>()
return { ...actual, after: vi.fn() }
})
vi.mock('../lib/oauth', () => ({
buildAuthorizeUrl: vi.fn().mockReturnValue('https://skv.test/authorize'),
generatePkcePair: vi.fn().mockReturnValue({ verifier: 'v', challenge: 'c' }),
exchangeCodeForTokens: vi.fn(),
}))
vi.mock('../lib/token-store', () => ({
storeTokens: vi.fn().mockResolvedValue(undefined),
getTokens: vi.fn().mockResolvedValue(null),
deleteTokens: vi.fn().mockResolvedValue(undefined),
getTokenHealth: vi.fn().mockResolvedValue(null),
markNeedsReconsent: vi.fn().mockResolvedValue(undefined),
RECONSENT_ERROR_CODES: ['SESSION_EXPIRED', 'REFRESH_EXHAUSTED', 'MISSING_SCOPE', 'TOKEN_CORRUPTED'],
}))
vi.mock('../lib/post-connect-refresh', () => ({
runPostConnectRefresh: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const { mockCreateClient } = vi.hoisted(() => ({ mockCreateClient: vi.fn() }))
vi.mock('@/lib/supabase/server', () => ({
createClient: mockCreateClient,
}))
import { after } from 'next/server'
import { skatteverketExtension } from '../index'
import { exchangeCodeForTokens } from '../lib/oauth'
import { storeTokens } from '../lib/token-store'
import { runPostConnectRefresh } from '../lib/post-connect-refresh'
const mockExchange = vi.mocked(exchangeCodeForTokens)
const mockStoreTokens = vi.mocked(storeTokens)
const mockRefresh = vi.mocked(runPostConnectRefresh)
const STATE = 'state-1'
/**
* Supabase mock covering the callback's extension_data reads (keyed lookups
* for oauth_state / oauth_redirect_uri / oauth_code_verifier /
* oauth_return_to) and the post-exchange cleanup delete.
*/
function makeSupabase(overrides: Record<string, string | null> = {}) {
const values: Record<string, string | null> = {
oauth_state: STATE,
oauth_redirect_uri: 'https://app.example/api/extensions/ext/skatteverket/callback',
oauth_code_verifier: 'verifier-1',
oauth_return_to: '/settings/tax',
...overrides,
}
const from = vi.fn(() => {
let key: string | null = null
const result = () => ({
data: key !== null && values[key] != null ? { value: values[key] } : null,
})
const chain: any = {
select: vi.fn(() => chain),
delete: vi.fn(() => chain),
eq: vi.fn((col: string, val: string) => {
if (col === 'key') key = val
return chain
}),
in: vi.fn(() => Promise.resolve({ error: null })),
single: vi.fn(async () => result()),
maybeSingle: vi.fn(async () => result()),
}
return chain
})
return {
auth: {
getUser: vi.fn(async () => ({ data: { user: { id: 'user-1' } } })),
},
from,
}
}
function callbackRoute() {
const route = skatteverketExtension.apiRoutes?.find(
(r) => r.method === 'GET' && r.path === '/callback',
)
expect(route, 'GET /callback must be registered').toBeDefined()
expect(route!.skipAuth).toBe(true)
return route!
}
function callbackRequest(params: string) {
return new Request(
`https://app.example/api/extensions/ext/skatteverket/callback?${params}`,
)
}
describe('skatteverket OAuth callback', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCreateClient.mockResolvedValue(makeSupabase() as any)
mockExchange.mockResolvedValue({
access_token: 'at',
refresh_token: 'rt',
expires_at: Date.now() + 3_600_000,
refresh_count: 0,
scope: 'momsdeklaration skahmst agd',
})
})
it('responds with the success page WITHOUT awaiting the post-connect refresh', async () => {
// A refresh that never settles: if the handler regressed to awaiting it,
// this test would hang into the vitest timeout instead of passing.
let refreshStarted = false
mockRefresh.mockImplementation(() => {
refreshStarted = true
return new Promise(() => {})
})
const response = await callbackRoute().handler(
callbackRequest(`code=abc&state=${STATE}`),
)
expect(response.status).toBe(200)
const html = await response.text()
expect(html).toContain('skatteverket-oauth-success')
expect(html).toContain('window.close()')
expect(mockExchange).toHaveBeenCalledWith(
'abc',
'https://app.example/api/extensions/ext/skatteverket/callback',
'verifier-1',
)
expect(mockStoreTokens).toHaveBeenCalledWith(
expect.anything(),
'user-1',
expect.objectContaining({ access_token: 'at' }),
'company-1',
)
// The refresh was started eagerly and handed to after() so it survives
// past the response; it must not gate the response itself.
expect(refreshStarted).toBe(true)
expect(vi.mocked(after)).toHaveBeenCalledTimes(1)
})
it('still succeeds when after() is unavailable (outside a request scope)', async () => {
mockRefresh.mockResolvedValue({ synced: true, reconciled: 0 })
vi.mocked(after).mockImplementation(() => {
throw new Error('after called outside request scope')
})
const response = await callbackRoute().handler(
callbackRequest(`code=abc&state=${STATE}`),
)
expect(response.status).toBe(200)
expect(await response.text()).toContain('skatteverket-oauth-success')
})
it('returns the error page on a state (CSRF) mismatch without exchanging the code', async () => {
const response = await callbackRoute().handler(
callbackRequest('code=abc&state=wrong-state'),
)
expect(response.status).toBe(200)
const html = await response.text()
expect(html).toContain('skatteverket-oauth-error')
expect(mockExchange).not.toHaveBeenCalled()
expect(mockRefresh).not.toHaveBeenCalled()
})
it('returns the error page when the token exchange fails', async () => {
mockExchange.mockRejectedValueOnce(new Error('exchange boom'))
const response = await callbackRoute().handler(
callbackRequest(`code=abc&state=${STATE}`),
)
expect(response.status).toBe(200)
const html = await response.text()
expect(html).toContain('skatteverket-oauth-error')
expect(mockRefresh).not.toHaveBeenCalled()
})
})
@@ -32,15 +32,35 @@ vi.mock('../lib/agi-kvittens-reconcile', () => ({
reconcileAgiDeclaration: vi.fn(),
}))
// Real SkatteverketAuthError shape (message, code) without dragging the full
// api-client module (and its fetch plumbing) into the test.
vi.mock('../lib/api-client', () => ({
SkatteverketAuthError: class SkatteverketAuthError extends Error {
code: string
constructor(message: string, code: string) {
super(message)
this.code = code
}
},
}))
vi.mock('../lib/token-store', () => ({
markNeedsReconsent: vi.fn().mockResolvedValue(undefined),
RECONSENT_ERROR_CODES: ['SESSION_EXPIRED', 'REFRESH_EXHAUSTED', 'MISSING_SCOPE', 'TOKEN_CORRUPTED'],
}))
import { runPostConnectRefresh } from '../lib/post-connect-refresh'
import { createExtensionContext } from '@/lib/extensions/context-factory'
import { hasCapability } from '@/lib/entitlements/has-capability'
import { syncSkattekonto } from '../lib/skattekonto-sync'
import { SkatteverketAuthError } from '../lib/api-client'
import { markNeedsReconsent } from '../lib/token-store'
import { reconcileAgiDeclaration } from '../lib/agi-kvittens-reconcile'
const mockCreateExtensionContext = vi.mocked(createExtensionContext)
const mockHasCapability = vi.mocked(hasCapability)
const mockSyncSkattekonto = vi.mocked(syncSkattekonto)
const mockMarkNeedsReconsent = vi.mocked(markNeedsReconsent)
const mockReconcile = vi.mocked(reconcileAgiDeclaration)
const USER = 'user-1'
@@ -118,6 +138,33 @@ describe('runPostConnectRefresh', () => {
const result = await runPostConnectRefresh(supabase, USER, COMPANY)
expect(result).toEqual({ synced: false, reconciled: 1 })
// A plain network/timeout failure is transient: it must not flag the
// freshly granted token as needing re-consent.
expect(mockMarkNeedsReconsent).not.toHaveBeenCalled()
})
it('persists needs_reconsent when the sync hits a terminal auth error (MISSING_SCOPE)', async () => {
mockSyncSkattekonto.mockRejectedValueOnce(
new SkatteverketAuthError('The required scopes are not authorized', 'MISSING_SCOPE'),
)
const supabase = makeSupabase({ data: [] })
const result = await runPostConnectRefresh(supabase, USER, COMPANY)
expect(result.synced).toBe(false)
expect(mockMarkNeedsReconsent).toHaveBeenCalledWith(supabase, USER, 'MISSING_SCOPE')
})
it('does not persist needs_reconsent for non-terminal auth error codes', async () => {
mockSyncSkattekonto.mockRejectedValueOnce(
new SkatteverketAuthError('temporary auth hiccup', 'NOT_CONNECTED'),
)
const supabase = makeSupabase({ data: [] })
const result = await runPostConnectRefresh(supabase, USER, COMPANY)
expect(result.synced).toBe(false)
expect(mockMarkNeedsReconsent).not.toHaveBeenCalled()
})
it('continues with remaining declarations when one reconcile throws', async () => {
+25 -30
View File
@@ -1,7 +1,7 @@
import crypto from 'crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import { NextResponse } from 'next/server'
import { NextResponse, after } from 'next/server'
import {
AGIKontrolleraHUSchema,
AGIKontrolleraIUSchema,
@@ -109,7 +109,8 @@ const log = createLogger('skatteverket')
* SKATTEVERKET_AGD_INLAMNING_API_BASE_URL=https://api.skatteverket.se/arbetsgivardeklaration/inlamning/v1
* SKATTEVERKET_AGD_PERIOD_API_BASE_URL=https://api.skatteverket.se/arbetsgivardeklaration/hanteraredovisningsperiod/v1
* SKATTEVERKET_SKATTEKONTO_API_BASE_URL=https://api.skatteverket.se/beskattning/skattekonto/v2
* SKATTEVERKET_OAUTH_BASE_URL=https://oauth2.skatteverket.se/oauth2
* SKATTEVERKET_OAUTH_BASE_URL=https://peroauth2.skatteverket.se/oauth2/v1/per
* (per-flow prod host, verified resolving; oauth2.skatteverket.se does not exist)
* 8. Verify Sentry alerts on /api/extensions/ext/skatteverket/* 5xx.
* 9. Verify 7-year retention of `agi_declarations.xml_content` +
* `kvittensnummer` (BFL 7 kap.).
@@ -433,35 +434,29 @@ export const skatteverketExtension: Extension = {
.eq('extension_id', 'skatteverket')
.in('key', ['oauth_state', 'oauth_return_to', 'oauth_code_verifier'])
// Refresh Skatteverket-derived data NOW, while the fresh token is
// guaranteed alive: SKV per-flow tokens live ~65 minutes, so the
// nightly crons usually find them dead and right-after-consent is
// the one reliable window for a personal-token fetch. Awaited on
// purpose: when the popup closes, the salary/skattekonto pages can
// refetch and see synced + auto-settled data instead of racing a
// background job. Best-effort: a refresh failure must never fail
// the connect that just succeeded.
// 30-second deadline: a hung SKV call must not hold the OAuth
// callback open. On timeout the refresh keeps running best-effort
// (no cancellation); the user still gets the success response.
const refreshTimeoutMs = 30000
// Refresh Skatteverket-derived data AFTER the response is sent.
// Right-after-consent is still the one reliable window for a
// personal-token fetch (SKV per-flow tokens live ~65 minutes), but
// the sync (skattekonto fetch + AGI auto-settle + kvittens
// re-checks) can take tens of seconds. This handler previously
// awaited it, which held the redirect open while the popup kept
// displaying SKV's already-consumed consent page; users read that
// as "I approved and nothing happened". The eager promise +
// after() pattern (mirrors the enable-banking finalize page) sends
// the success page immediately and keeps the serverless function
// alive until the refresh settles; the connect panels do a delayed
// status refetch to pick up the synced data. Best-effort: a
// refresh failure must never fail the connect that just succeeded.
const refreshPromise = runPostConnectRefresh(supabase, user.id, companyId)
.then(() => undefined)
.catch((refreshErr) => {
log.error('post-connect refresh failed', refreshErr, { companyId, userId: user.id })
})
try {
let timer: ReturnType<typeof setTimeout> | undefined
const timedOut = await Promise.race([
runPostConnectRefresh(supabase, user.id, companyId).then(() => false),
new Promise<true>((resolve) => {
timer = setTimeout(() => resolve(true), refreshTimeoutMs)
}),
]).finally(() => clearTimeout(timer))
if (timedOut) {
log.warn('post-connect refresh timed out', {
companyId,
userId: user.id,
timeoutMs: refreshTimeoutMs,
})
}
} catch (refreshErr) {
log.error('post-connect refresh failed', refreshErr, { companyId, userId: user.id })
after(() => refreshPromise)
} catch {
// Outside a request scope (unit tests, plain node server): the
// eager promise still drives the refresh to completion.
}
return respondWithSuccess(successPath)
@@ -4,6 +4,8 @@ import { hasCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { createLogger } from '@/lib/logger'
import { syncSkattekonto } from './skattekonto-sync'
import { SkatteverketAuthError } from './api-client'
import { markNeedsReconsent, RECONSENT_ERROR_CODES } from './token-store'
import { reconcileAgiDeclaration, type PendingAgiDeclaration } from './agi-kvittens-reconcile'
const log = createLogger('skatteverket-post-connect')
@@ -64,6 +66,18 @@ export async function runPostConnectRefresh(
companyId,
message: err instanceof Error ? err.message : String(err),
})
// A terminal auth error immediately after a fresh consent means the
// just-granted token itself is unusable; in practice MISSING_SCOPE,
// when the user skipped a behörighet on SKV's consent page. Persist
// the health flag (same mechanism as the crons) so /status reports
// needsReconsent and the connect panel can prompt with a specific
// "godkänn alla behörigheter" message instead of a silent success.
if (
err instanceof SkatteverketAuthError &&
(RECONSENT_ERROR_CODES as readonly string[]).includes(err.code)
) {
await markNeedsReconsent(supabase, userId, err.code)
}
}
try {