Bug/invalid imports (#1146)

* feat: add Accounted MCP namespace

* fix(bookkeeping): stop flagging verifikat whose underlag lives on a referenced supplier invoice

The missing-underlag surfaces only accepted a document directly linked to
the entry, so payment verifikat for supplier invoices (doc on the
registration entry per design) and entries whose doc was pinned to the
bank transaction before matching were falsely flagged; opening the entry
showed the referenced doc and cleared the warning client-side, and it
came back on reload.

- verifikat_without_documents + transactions_without_documents now treat
  an entry as covered when a supplier invoice referencing it (registration
  or payment FK, or a supplier_invoice_payments row) carries a document
  anchored to a journal entry (BFL 5 kap 7 paragraf hänvisning till
  underlag; anchoring required because the WORM deletion guards key on
  document_attachments.journal_entry_id)
- match-supplier-invoice routes (dashboard + v1) propagate the
  transaction's pinned document onto the payment verifikat, mirroring the
  categorize route; migration backfills rows already written (open
  unlocked periods, company-guarded, never steals a linked doc)
- /api/documents/counts, the transactions-page badges, the bulk "Inget
  underlag krävs" count and the push-notification scheduler share the
  same reference-aware predicate, so every surface agrees with the RPC
- counts route validates journal_entry_ids as UUIDs (they are
  interpolated into a PostgREST or-filter)

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

* fix(transactions): align table columns flush with page edges

Collapse the checkbox gutter column to zero width and hang the
hover-revealed checkbox/expand chevron in the page margins, drop the
outer padding so DATUM sits flush left and STATUS flush right, and
tuck the overflow-menu dots under the middle of the STATUS header.
Applied to both the inbox and history tables so they stay identical.

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

* fix(arsredovisning): tie anlaggningstillgangar note to booked depreciation

The ARL 5:8 roll-forward note recomputed depreciation from its own
day-based linear formula (365.25/12 month length, non-inclusive day
count, linear only), drifting ~20 kr per year per asset from the
ledger-driven resultat- and balansrakning and misstating non-linear
methods entirely. Note figures now come from posted
depreciation_schedules rows (the same source disposeAsset reverses),
falling back to the engine's computeAnnualDepreciation when nothing is
posted; pre-onboarding opening balances iterate prior years through
the engine. Adds a note-vs-trial-balance tie-out warning (accounts
1000-1299, over 1 kr) surfaced before download.

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

* refactor(stripe): move connect and sync surface from settings to import page

Stripe's transaction feed is a continuous import source in the same
category as the PSD2 bank connection, so its connect/sync surface now
lives on the import page as a source card (mode=stripe), gated
"kommer snart" on hosted like before; self-hosted keeps the full panel.

- Import page: Stripe card after Koppla bank, renders the existing
  StripeSettingsPanel via the settings-panel registry
- OAuth callback and panel cleanup return to /import?mode=stripe
- Settings > Betalningar retired: nav item removed, route redirects,
  PaymentsSettingsContent deleted, legacy ?tab=payments mapped
- New import.stripe_* strings in sv+en; dead settings_nav.payments removed

Crons and sync logic unchanged; payment-link settings stay in the
invoicing section.

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

* fix(underlag): paginate missing-underlag cron and harden doc-surface queries

Resolve PR review findings on bug/invalid-imports:
- notification-scheduler: fetchAllRows on all 5 global reads; past 1000 rows
  the capped reads produced false "saknade underlag" notifications
- bulk-missing: LOOKUP_CHUNK 300->150 so the twice-embedded .or() id list
  stays under the PostgREST URL limit
- bulk-missing + transactions page: UUID-guard the .or()-interpolated id
  lists, matching documents/counts
- match-supplier-invoice (dashboard + v1): log documentId/journalEntryId on
  the non-fatal doc-link warning
- well-known/oauth-protected-resource: document the tool_namespace allow-list
- messages/en: reword stripe_description
- DECISIONS.md: record the asset ibAck tie-out and Tailwind !important calls

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(tic): convert registrationDate from Unix seconds to millisecond epoch in lookup and profile tests

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-24 15:03:50 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 51ca574ca4
commit 53e343ee92
71 changed files with 3085 additions and 452 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Accounted MCP server
JSON-RPC 2.0 server exposing the Accounted bookkeeping engine to MCP clients (Claude Desktop, Claude Code, etc.). Endpoint: `/api/extensions/ext/mcp-server/mcp`. OAuth and stdio bridge live alongside the API surface: see `app/api/mcp-oauth/` and `packages/gnubok-mcp/`.
JSON-RPC 2.0 server exposing the Accounted bookkeeping engine to MCP clients (Claude Desktop, Claude Code, etc.). Endpoint: `/api/extensions/ext/mcp-server/mcp`. Add `?tool_namespace=accounted` for the Accounted tool names. Requests without it retain the legacy Gnubok namespace. OAuth and stdio bridges live alongside the API surface: see `app/api/mcp-oauth/`, `packages/accounted-mcp/`, and the compatibility package in `packages/gnubok-mcp/`.
## Tool authoring contract
@@ -3,6 +3,7 @@ import { readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import { workflowSkills } from '../skills'
import { dataResources } from '../resources'
import { toCanonicalToolName } from '../tool-namespace'
import { discoverAtoms } from '@/scripts/lib/atom-discovery'
/**
@@ -38,11 +39,11 @@ describe('claude-plugin wrapper references', () => {
])
})
it('every gnubok_load_skill slug resolves to a workflow skill or a registry atom', async () => {
it('every accounted_load_skill slug resolves to a workflow skill or a registry atom', async () => {
const workflowSlugs = new Set(workflowSkills.map((s) => s.slug))
const atomIds = new Set((await discoverAtoms(repoRoot)).map((a) => a.id))
for (const { file, body } of pluginSkills) {
for (const [, slug] of body.matchAll(/gnubok_load_skill\("([^"]+)"\)/g)) {
for (const [, slug] of body.matchAll(/accounted_load_skill\("([^"]+)"\)/g)) {
const known = workflowSlugs.has(slug) || atomIds.has(slug)
expect(known, `${file} references unknown skill slug "${slug}"`).toBe(true)
}
@@ -58,11 +59,12 @@ describe('claude-plugin wrapper references', () => {
}
})
it('every gnubok_* tool name exists on the server', () => {
it('every accounted_* tool name resolves to a canonical server tool', () => {
for (const { file, body } of pluginSkills) {
for (const [tool] of body.matchAll(/gnubok_[a-z_]+/g)) {
for (const [tool] of body.matchAll(/accounted_[a-z_]+/g)) {
const canonicalTool = toCanonicalToolName(tool)
expect(
serverSource.includes(`name: '${tool}'`),
serverSource.includes(`name: '${canonicalTool}'`),
`${file} references unknown tool "${tool}"`,
).toBe(true)
}
@@ -0,0 +1,197 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { eventBus } from '@/lib/events/bus'
const mocks = vi.hoisted(() => ({
scopes: [] as string[],
}))
vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/auth/api-keys')>()
mocks.scopes = [...actual.ALL_SCOPES]
return {
...actual,
extractBearerToken: vi.fn().mockReturnValue('test-token'),
validateApiKey: vi.fn().mockResolvedValue({
userId: 'user-1',
companyId: '11111111-1111-4111-8111-111111111111',
scopes: mocks.scopes,
apiKeyId: 'key-1',
apiKeyName: 'Test key',
mode: 'live',
}),
createServiceClientNoCookies: vi.fn(() => ({})),
}
})
import { handleMcpRequest, tools as canonicalTools } from '../server'
import { toPublicToolName } from '../tool-namespace'
function mcpRequest(
method: string,
params?: Record<string, unknown>,
namespace?: 'accounted'
): Request {
const url = new URL('http://localhost:3000/api/extensions/ext/mcp-server/mcp')
if (namespace) url.searchParams.set('tool_namespace', namespace)
return new Request(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer test-token',
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method,
...(params ? { params } : {}),
}),
})
}
async function readResult(request: Request): Promise<Record<string, unknown>> {
const response = await handleMcpRequest(request)
const body = await response.json()
return body.result as Record<string, unknown>
}
describe('MCP namespace compatibility', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
it('maps every canonical tool to one valid, unique Accounted name', () => {
const accountedNames = canonicalTools.map((tool) =>
toPublicToolName(tool.name, 'accounted')
)
expect(new Set(accountedNames).size).toBe(canonicalTools.length)
for (const name of accountedNames) {
expect(name).toMatch(/^[A-Za-z0-9_.\-/]{1,64}$/)
expect(name).toMatch(/^accounted_/)
}
})
it('keeps the legacy server identity and tool catalog by default', async () => {
const initialized = await readResult(
mcpRequest('initialize', {
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 'test', version: '1.0.0' },
})
)
const serverInfo = initialized.serverInfo as Record<string, unknown>
expect(serverInfo.name).toBe('gnubok')
expect(initialized.instructions).toContain('gnubok_search_tools')
const listed = await readResult(mcpRequest('tools/list'))
const tools = listed.tools as Array<{ name: string }>
expect(tools.length).toBeGreaterThan(0)
expect(tools.every((tool) => tool.name.startsWith('gnubok_'))).toBe(true)
})
it('advertises the Accounted identity, tools, and staging references when selected', async () => {
const initialized = await readResult(
mcpRequest(
'initialize',
{
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 'test', version: '1.0.0' },
},
'accounted'
)
)
const serverInfo = initialized.serverInfo as Record<string, unknown>
expect(serverInfo.name).toBe('accounted')
expect(initialized.instructions).toContain('accounted_search_tools')
const listed = await readResult(mcpRequest('tools/list', undefined, 'accounted'))
const tools = listed.tools as Array<{
name: string
_meta?: { approve_tool?: string; preflight?: string }
}>
expect(tools.length).toBeGreaterThan(0)
expect(tools.every((tool) => tool.name.startsWith('accounted_'))).toBe(true)
const canonicalNames = new Set(canonicalTools.map((tool) => tool.name))
const leakedReferences =
JSON.stringify(tools).match(/\bgnubok_[A-Za-z0-9_]+\b/g)?.filter((name) =>
canonicalNames.has(name)
) ?? []
expect(leakedReferences).toEqual([])
const stagingTool = tools.find((tool) => tool._meta?.approve_tool)
expect(stagingTool?._meta?.approve_tool).toBe(
'accounted_approve_pending_operation'
)
if (stagingTool?._meta?.preflight) {
expect(stagingTool._meta.preflight).toMatch(/^accounted_/)
}
})
it('accepts both aliases while returning the selected public namespace', async () => {
const params = {
arguments: {
query: 'list companies',
detail: 'name',
limit: 10,
},
}
const accountedCall = await readResult(
mcpRequest(
'tools/call',
{ ...params, name: 'accounted_search_tools' },
'accounted'
)
)
const legacyAliasCall = await readResult(
mcpRequest(
'tools/call',
{ ...params, name: 'gnubok_search_tools' },
'accounted'
)
)
expect(accountedCall.structuredContent).toEqual(
legacyAliasCall.structuredContent
)
const structured = accountedCall.structuredContent as {
tools: Array<{ name: string }>
}
expect(structured.tools.length).toBeGreaterThan(0)
expect(
structured.tools.every((tool) => tool.name.startsWith('accounted_'))
).toBe(true)
})
it('projects prompt and loaded-skill tool references for Accounted clients', async () => {
const prompt = await readResult(
mcpRequest(
'prompts/get',
{ name: 'cash_today' },
'accounted'
)
)
const messages = prompt.messages as Array<{
content: { text: string }
}>
expect(messages[0].content.text).toContain('accounted_get_balance_sheet')
expect(messages[0].content.text).not.toContain('gnubok_get_balance_sheet')
const loaded = await readResult(
mcpRequest(
'tools/call',
{
name: 'accounted_load_skill',
arguments: { slug: 'month-end-close' },
},
'accounted'
)
)
const structured = loaded.structuredContent as { body: string }
expect(structured.body).toContain('accounted_list_uncategorized_transactions')
expect(structured.body).not.toContain('gnubok_list_uncategorized_transactions')
})
})
@@ -241,6 +241,32 @@ describe('mcp.tool_called telemetry', () => {
expect(event.latencyMs).toBe(0)
})
it('applies the canonical scope gate to an Accounted alias', async () => {
const eventPromise = captureNextToolCalledEvent()
const response = await handleMcpRequest(
mcpRequest(
'tools/call',
{
name: 'accounted_create_invoice',
arguments: { customer_id: 'x', items: [] },
},
1,
{
url: 'http://localhost:3000/api/extensions/ext/mcp-server/mcp?tool_namespace=accounted',
}
)
)
const body = await response.json()
const payload = JSON.parse(body.result.content[0].text)
expect(payload.error.code).toBe('INSUFFICIENT_SCOPE')
const event = await eventPromise
expect(event.tool).toBe('gnubok_create_invoice')
expect(event.requiredScope).toBe('invoices:write')
expect(event.errorKind).toBe('scope_denied')
})
it('emits errorKind=unknown_tool when the tool name does not exist', async () => {
const eventPromise = captureNextToolCalledEvent()
@@ -325,6 +351,21 @@ describe('client marker telemetry', () => {
expect(event.client).toBe('openclaw')
})
it('records the Accounted client header on the same telemetry field', async () => {
const eventPromise = captureNextToolCalledEvent()
await handleMcpRequest(
mcpRequest('tools/call', { name: 'accounted_list_skills', arguments: {} }, 1, {
url: 'http://localhost:3000/api/extensions/ext/mcp-server/mcp?tool_namespace=accounted',
headers: { 'X-Accounted-Client': 'Claude-Desktop' },
})
)
const event = await eventPromise
expect(event.client).toBe('claude-desktop')
expect(event.tool).toBe('gnubok_list_skills')
})
it('falls back to the ?client= query param when no header is present', async () => {
const eventPromise = captureNextToolsListEvent()
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest'
import {
canonicalizeToolReferencesInText,
projectToolReferences,
resolveMcpToolNamespace,
toCanonicalToolName,
toPublicToolName,
} from '../tool-namespace'
const registered = new Set([
'gnubok_search_tools',
'gnubok_list_companies',
'gnubok_approve_pending_operation',
])
describe('MCP tool namespaces', () => {
it('keeps the legacy namespace unless Accounted is explicitly requested', () => {
expect(resolveMcpToolNamespace(new Request('https://example.test/mcp'))).toBe('gnubok')
expect(
resolveMcpToolNamespace(
new Request('https://example.test/mcp?tool_namespace=unknown')
)
).toBe('gnubok')
expect(
resolveMcpToolNamespace(
new Request('https://example.test/mcp?tool_namespace=accounted')
)
).toBe('accounted')
})
it('maps Accounted aliases to canonical tool names', () => {
expect(toCanonicalToolName('accounted_list_companies')).toBe(
'gnubok_list_companies'
)
expect(toCanonicalToolName('gnubok_list_companies')).toBe(
'gnubok_list_companies'
)
expect(toPublicToolName('gnubok_list_companies', 'accounted')).toBe(
'accounted_list_companies'
)
expect(toPublicToolName('gnubok_list_companies', 'gnubok')).toBe(
'gnubok_list_companies'
)
})
it('canonicalizes Accounted references in search queries', () => {
expect(
canonicalizeToolReferencesInText(
'Find accounted_list_companies and accounted_search_tools'
)
).toBe('Find gnubok_list_companies and gnubok_search_tools')
})
it('projects only registered tool references and preserves wire identifiers', () => {
const result = projectToolReferences(
{
message:
'Call gnubok_list_companies, then gnubok_approve_pending_operation.',
key: 'gnubok_sk_test_example',
unknown: 'gnubok_not_a_registered_tool',
next: { tool: 'gnubok_search_tools' },
},
'accounted',
registered
)
expect(result).toEqual({
message:
'Call accounted_list_companies, then accounted_approve_pending_operation.',
key: 'gnubok_sk_test_example',
unknown: 'gnubok_not_a_registered_tool',
next: { tool: 'accounted_search_tools' },
})
})
})
+1 -1
View File
@@ -13,6 +13,6 @@
"dataPattern": "manual",
"hasOwnData": false,
"description": "Gör bokföring via Claude, Cursor eller annan MCP-klient",
"longDescription": "Exponerar gnuboks bokföringsmotor som MCP-verktyg (Model Context Protocol). Koppla din MCP-klient med en API-nyckel och gör bokföring genom konversation: visa okategoriserade transaktioner, bokför dem, skapa fakturor."
"longDescription": "Exponerar Accounteds bokföringsmotor som MCP-verktyg (Model Context Protocol). Koppla din MCP-klient med en API-nyckel och gör bokföring genom konversation: visa okategoriserade transaktioner, bokför dem, skapa fakturor."
}
}
+167 -65
View File
@@ -37,6 +37,15 @@ import { prompts, findPrompt } from './prompts'
import { findSkill, loadAllSkills, toSummary, SKILL_MIME_TYPE, SKILL_URI_PREFIX, skillUri, skillSlugFromUri } from './skills'
import type { SkillTier } from './skills'
import { RECOMMENDED_WORKFLOW_LOADOUTS, assertRecommendedLoadoutsValid } from './recommended-tools'
import {
canonicalizeToolReferencesInText,
projectToolReferences,
projectToolReferencesInText,
resolveMcpToolNamespace,
toCanonicalToolName,
toPublicToolName,
type McpToolNamespace,
} from './tool-namespace'
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'
@@ -146,9 +155,10 @@ interface ActorContext {
*/
sessionId?: string | null
/**
* Distribution-channel marker from the `X-Gnubok-Client` header or the
* `client` query param on the endpoint URL (e.g. 'openclaw'). Telemetry-only:
* same trust level as Mcp-Session-Id, never used for auth or behavior.
* Distribution-channel marker from `X-Accounted-Client`, the legacy
* `X-Gnubok-Client`, or the `client` query param (e.g. 'openclaw').
* Telemetry-only: same trust level as Mcp-Session-Id, never used for auth or
* behavior.
*/
client?: string | null
}
@@ -1683,6 +1693,17 @@ const DIMENSION_FILTER_OUTPUT_PROPS = {
},
} as const
let canonicalToolNamesCache: ReadonlySet<string> | undefined
function getCanonicalToolNames(): ReadonlySet<string> {
canonicalToolNamesCache ??= new Set(tools.map((tool) => tool.name))
return canonicalToolNamesCache
}
function projectMcpPayload<T>(value: T, namespace: McpToolNamespace): T {
return projectToolReferences(value, namespace, getCanonicalToolNames())
}
// ── Tools ────────────────────────────────────────────────────
export const tools: McpTool[] = [
@@ -1718,7 +1739,11 @@ export const tools: McpTool[] = [
openWorldHint: false,
},
async execute(args, _companyId, _userId, _supabase, _actor) {
const query = ((args.query as string) || '').toLowerCase().trim()
const namespace: McpToolNamespace =
args.__toolNamespace === 'accounted' ? 'accounted' : 'gnubok'
const query = canonicalizeToolReferencesInText(
((args.query as string) || '').toLowerCase().trim()
)
const detail = ((args.detail as string) || 'summary') as 'name' | 'summary' | 'full'
const scopeFilter = args.scope as string | undefined
const limit = Math.min(Math.max(1, Number(args.limit) || 20), 50)
@@ -1781,21 +1806,36 @@ export const tools: McpTool[] = [
const projected = sliced.map((t) => {
const requiredScope = TOOL_SCOPE_MAP[t.name] ?? null
if (detail === 'name') return { name: t.name, scope: requiredScope }
if (detail === 'name') {
return { name: toPublicToolName(t.name, namespace), scope: requiredScope }
}
if (detail === 'full') {
const meta = { ...(deriveToolMeta(t) ?? {}), ...(t._meta ?? {}) }
return {
name: t.name,
description: t.description,
scope: requiredScope,
inputSchema: projectToolInputSchema(t),
...(t.outputSchema ? { outputSchema: t.outputSchema } : {}),
annotations: t.annotations,
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
}
const meta = projectMcpPayload(
{ ...(deriveToolMeta(t) ?? {}), ...(t._meta ?? {}) },
namespace
)
return projectMcpPayload(
{
name: toPublicToolName(t.name, namespace),
description: t.description,
scope: requiredScope,
inputSchema: projectToolInputSchema(t),
...(t.outputSchema ? { outputSchema: t.outputSchema } : {}),
annotations: t.annotations,
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
},
namespace
)
}
// summary (default)
return { name: t.name, description: t.description, scope: requiredScope }
return projectMcpPayload(
{
name: toPublicToolName(t.name, namespace),
description: t.description,
scope: requiredScope,
},
namespace
)
})
return {
@@ -13614,14 +13654,19 @@ assertRecommendedLoadoutsValid(new Set(tools.map((t) => t.name)))
// ── MCP Protocol Handler ─────────────────────────────────────
const SERVER_INFO = {
// `name` is a stable identifier clients may key state on: stays 'gnubok'
// per the rebrand rule. `title` is the human-readable display name
// (MCP spec 2025-06-18).
name: 'gnubok',
title: 'Accounted',
version: '1.0.0',
}
const SERVER_INFO_BY_NAMESPACE = {
gnubok: {
// Stable legacy identity for every existing connection.
name: 'gnubok',
title: 'Accounted',
version: '1.0.0',
},
accounted: {
name: 'accounted',
title: 'Accounted',
version: '1.0.0',
},
} as const
const PROTOCOL_VERSION = '2025-06-18'
@@ -13891,8 +13936,13 @@ function emitWorkflowStarted(payload: {
* Auth is done via Bearer API key (extension route has skipAuth: true).
*/
export async function handleMcpRequest(request: Request): Promise<Response> {
const toolNamespace = resolveMcpToolNamespace(request)
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
const wwwAuth = `Bearer resource_metadata="${appUrl}/.well-known/oauth-protected-resource"`
const resourceMetadataUrl = new URL('/.well-known/oauth-protected-resource', appUrl)
if (toolNamespace === 'accounted') {
resourceMetadataUrl.searchParams.set('tool_namespace', 'accounted')
}
const wwwAuth = `Bearer resource_metadata="${resourceMetadataUrl.toString()}"`
// ── Pre-auth: handle fire-and-forget notifications before auth check ──
// MCP notifications have no id and don't expect error responses.
@@ -13939,12 +13989,13 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
// followed metric. It is NOT used for auth.
const rawSessionId = request.headers.get('mcp-session-id')
const sessionId = rawSessionId && /^[A-Za-z0-9_-]{1,128}$/.test(rawSessionId) ? rawSessionId : null
// Distribution-channel marker: `X-Gnubok-Client` header (gnubok-mcp bridge
// ≥1.1 forwards GNUBOK_CLIENT) or a `client` query param on the endpoint URL
// (works with any bridge version and direct HTTP clients). Lets us measure
// per-channel adoption (e.g. an OpenClaw skill) in event_log without auth
// implications.
const rawClient = request.headers.get('x-gnubok-client') ?? new URL(request.url).searchParams.get('client')
// Distribution-channel marker: the Accounted bridge sends
// `X-Accounted-Client`; the legacy bridge keeps `X-Gnubok-Client`. Both are
// telemetry-only and share the same validation and storage path.
const rawClient =
request.headers.get('x-accounted-client') ??
request.headers.get('x-gnubok-client') ??
new URL(request.url).searchParams.get('client')
const client = rawClient && /^[A-Za-z0-9._-]{1,64}$/.test(rawClient) ? rawClient.toLowerCase() : null
const actor: ActorContext = {
type: 'api_key',
@@ -13989,8 +14040,8 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
resources: { listChanged: false },
prompts: { listChanged: false },
},
serverInfo: SERVER_INFO,
instructions: [
serverInfo: SERVER_INFO_BY_NAMESPACE[toolNamespace],
instructions: projectToolReferencesInText([
'Accounted: Swedish double-entry bookkeeping via conversation.',
'',
'Discovery:',
@@ -14017,8 +14068,10 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
'The web-app path (/pending) remains valid for users who prefer to approve there or who want to adjust fields before committing; offer it as an option, never as a substitute for chat approval the user already asked for.',
'Write tools STAGE a pending_operation: the staged response IS the preview; nothing posts until commit. A tool whose tools/list `_meta.requires_approval` is true stages for approval; `_meta.preflight` (when present) names a read-only check to run first (e.g. gnubok_year_end_readiness before gnubok_run_year_end, gnubok_vat_declaration_validate before _submit). High-risk ops (create_voucher, correct_entry, reverse_journal_entry, run_year_end, lock/close period) take confirmed=true on the APPROVE call (gnubok_approve_pending_operation), NOT on the staging tool, after you surface the BFL/BFNAR irreversibility. Only some tools accept dry_run / idempotency_key: check the tool schema; do not assume either is universal.',
'All amounts are SEK unless currency is specified. All dates ISO YYYY-MM-DD. Account numbers are strings (e.g. "1930").',
'Tool names carry the legacy gnubok_ prefix (a stable identifier kept across the rebrand); the server and app are "Accounted". Same product: the prefix is not a different system.',
].join('\n'),
toolNamespace === 'gnubok'
? 'Tool names carry the legacy gnubok_ prefix (a stable identifier kept across the rebrand); the server and app are "Accounted". Same product: the prefix is not a different system.'
: 'Tool names use the accounted_ prefix. Legacy gnubok_ aliases remain accepted for existing integrations.',
].join('\n'), toolNamespace, getCanonicalToolNames()),
})
)
}
@@ -14051,23 +14104,32 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
// Merge derived staging metadata with any literal _meta (e.g. UI
// widget hints). Literal _meta wins on key collision so explicit
// tool config is never clobbered.
const meta = { ...(deriveToolMeta(t) ?? {}), ...(t._meta ?? {}) }
return {
name: t.name,
...(t.title ? { title: t.title } : {}),
description: t.description,
inputSchema: projectToolInputSchema(t),
...(t.outputSchema ? { outputSchema: t.outputSchema } : {}),
annotations: t.annotations,
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
}
const meta = projectMcpPayload(
{ ...(deriveToolMeta(t) ?? {}), ...(t._meta ?? {}) },
toolNamespace
)
return projectMcpPayload(
{
name: toPublicToolName(t.name, toolNamespace),
...(t.title ? { title: t.title } : {}),
description: t.description,
inputSchema: projectToolInputSchema(t),
...(t.outputSchema ? { outputSchema: t.outputSchema } : {}),
annotations: t.annotations,
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
},
toolNamespace
)
}),
})
)
}
case 'tools/call': {
const toolName = (params as Record<string, unknown>)?.name as string
const rawRequestedToolName = (params as Record<string, unknown>)?.name
const requestedToolName =
typeof rawRequestedToolName === 'string' ? rawRequestedToolName : ''
const toolName = toCanonicalToolName(requestedToolName)
const rawToolArgs = ((params as Record<string, unknown>)?.arguments ?? {}) as Record<
string,
unknown
@@ -14092,9 +14154,15 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
userId,
companyId,
})
const available = tools.map((t) => t.name).join(', ')
const available = tools
.map((t) => toPublicToolName(t.name, toolNamespace))
.join(', ')
return NextResponse.json(
jsonRpcError(id ?? null, -32602, `Unknown tool: "${toolName}". Available tools: ${available}`)
jsonRpcError(
id ?? null,
-32602,
`Unknown tool: "${requestedToolName}". Available tools: ${available}`
)
)
}
@@ -14119,9 +14187,10 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
userId,
companyId,
})
const publicScopeError = projectMcpPayload(scopeError, toolNamespace)
return NextResponse.json(
jsonRpc(id ?? null, {
content: [{ type: 'text', text: JSON.stringify(scopeError, null, 2) }],
content: [{ type: 'text', text: JSON.stringify(publicScopeError, null, 2) }],
isError: true,
})
)
@@ -14146,6 +14215,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
}
} catch (err) {
const structured = toToolError(err, { toolName })
const publicStructured = projectMcpPayload(structured, toolNamespace)
emitToolCallTelemetry({
tool: toolName,
requiredScope: requiredScope ?? null,
@@ -14164,7 +14234,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
})
return NextResponse.json(
jsonRpc(id ?? null, {
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
content: [{ type: 'text', text: JSON.stringify(publicStructured, null, 2) }],
isError: true,
})
)
@@ -14177,6 +14247,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
const requiredCapability = MCP_TOOL_CAPABILITY_MAP[toolName]
if (requiredCapability && !(await hasCapability(supabase, effectiveCompanyId, requiredCapability))) {
const capError = { error: capabilityBlockedError(requiredCapability) }
const publicCapError = projectMcpPayload(capError, toolNamespace)
emitToolCallTelemetry({
tool: toolName,
requiredScope,
@@ -14193,7 +14264,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
})
return NextResponse.json(
jsonRpc(id ?? null, {
content: [{ type: 'text', text: JSON.stringify(capError, null, 2) }],
content: [{ type: 'text', text: JSON.stringify(publicCapError, null, 2) }],
isError: true,
})
)
@@ -14217,6 +14288,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
),
{ toolName }
)
const publicBlocked = projectMcpPayload(blocked, toolNamespace)
emitToolCallTelemetry({
tool: toolName,
requiredScope: requiredScope ?? null,
@@ -14233,7 +14305,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
})
return NextResponse.json(
jsonRpc(id ?? null, {
content: [{ type: 'text', text: JSON.stringify(blocked, null, 2) }],
content: [{ type: 'text', text: JSON.stringify(publicBlocked, null, 2) }],
isError: true,
})
)
@@ -14251,9 +14323,11 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
// what the API key can actually invoke. Inject privately via __keyScopes.
if (toolName === 'gnubok_search_tools') {
(toolArgs as Record<string, unknown>).__keyScopes = keyScopes
;(toolArgs as Record<string, unknown>).__toolNamespace = toolNamespace
}
const rawResult = await tool.execute(toolArgs, effectiveCompanyId, userId, supabase, actor)
const result = addCompanyToTopLevelNext(rawResult, effectiveCompanyId)
const canonicalResult = addCompanyToTopLevelNext(rawResult, effectiveCompanyId)
const result = projectMcpPayload(canonicalResult, toolNamespace)
const latencyMs = Date.now() - callStartedAt
const response: Record<string, unknown> = {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
@@ -14273,8 +14347,12 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
}
// Record the response's `next.tool` (when present) so the next call
// from the same session can be matched against it.
if (result && typeof result === 'object' && !Array.isArray(result)) {
const next = (result as Record<string, unknown>).next
if (
canonicalResult &&
typeof canonicalResult === 'object' &&
!Array.isArray(canonicalResult)
) {
const next = (canonicalResult as Record<string, unknown>).next
if (next && typeof next === 'object') {
const suggestedTool = (next as Record<string, unknown>).tool
if (typeof suggestedTool === 'string') {
@@ -14300,6 +14378,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
} catch (err) {
const latencyMs = Date.now() - callStartedAt
const structured = toToolError(err, { toolName })
const publicStructured = projectMcpPayload(structured, toolNamespace)
emitToolCallTelemetry({
tool: toolName,
requiredScope: requiredScope ?? null,
@@ -14319,7 +14398,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
})
return NextResponse.json(
jsonRpc(id ?? null, {
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
content: [{ type: 'text', text: JSON.stringify(publicStructured, null, 2) }],
isError: true,
})
)
@@ -14329,7 +14408,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
case 'resources/list': {
const allSkills = await loadAllSkills(supabase)
return NextResponse.json(
jsonRpc(id ?? null, {
jsonRpc(id ?? null, projectMcpPayload({
resources: [
...uiWidgets.map((w) => ({
uri: w.uri,
@@ -14350,7 +14429,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
mimeType: r.mimeType,
})),
],
})
}, toolNamespace))
)
}
@@ -14377,7 +14456,11 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
{
uri,
mimeType: WIDGET_MIME_TYPE,
text: widget.html,
text: projectToolReferencesInText(
widget.html,
toolNamespace,
getCanonicalToolNames()
),
},
],
})
@@ -14408,7 +14491,11 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
{
uri,
mimeType: SKILL_MIME_TYPE,
text: skill.body,
text: projectToolReferencesInText(
skill.body,
toolNamespace,
getCanonicalToolNames()
),
},
],
})
@@ -14443,7 +14530,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
{
uri,
mimeType: dataResource.mimeType,
text: JSON.stringify(result, null, 2),
text: JSON.stringify(projectMcpPayload(result, toolNamespace), null, 2),
},
],
})
@@ -14462,7 +14549,15 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
companyId,
})
return NextResponse.json(
jsonRpcError(id ?? null, -32603, `Resource read error: ${message}`)
jsonRpcError(
id ?? null,
-32603,
projectToolReferencesInText(
`Resource read error: ${message}`,
toolNamespace,
getCanonicalToolNames()
)
)
)
}
}
@@ -14485,12 +14580,12 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
case 'prompts/list':
return NextResponse.json(
jsonRpc(id ?? null, {
jsonRpc(id ?? null, projectMcpPayload({
prompts: prompts.map((p) => ({
name: p.name,
description: p.description,
})),
})
}, toolNamespace))
)
case 'prompts/get': {
@@ -14507,7 +14602,14 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
messages: [
{
role: 'user',
content: { type: 'text', text: prompt.text },
content: {
type: 'text',
text: projectToolReferencesInText(
prompt.text,
toolNamespace,
getCanonicalToolNames()
),
},
},
],
})
@@ -0,0 +1,73 @@
export type McpToolNamespace = 'gnubok' | 'accounted'
export const TOOL_NAMESPACE_QUERY_PARAM = 'tool_namespace'
const LEGACY_TOOL_PREFIX = 'gnubok_'
const ACCOUNTED_TOOL_PREFIX = 'accounted_'
const LEGACY_TOOL_REFERENCE_RE = /\bgnubok_[A-Za-z0-9_]+\b/g
const ACCOUNTED_TOOL_REFERENCE_RE = /\baccounted_[A-Za-z0-9_]+\b/g
export function resolveMcpToolNamespace(request: Request): McpToolNamespace {
const requested = new URL(request.url).searchParams.get(TOOL_NAMESPACE_QUERY_PARAM)
return requested === 'accounted' ? 'accounted' : 'gnubok'
}
export function toCanonicalToolName(name: string): string {
if (!name.startsWith(ACCOUNTED_TOOL_PREFIX)) return name
return `${LEGACY_TOOL_PREFIX}${name.slice(ACCOUNTED_TOOL_PREFIX.length)}`
}
export function toPublicToolName(name: string, namespace: McpToolNamespace): string {
if (namespace !== 'accounted' || !name.startsWith(LEGACY_TOOL_PREFIX)) return name
return `${ACCOUNTED_TOOL_PREFIX}${name.slice(LEGACY_TOOL_PREFIX.length)}`
}
export function canonicalizeToolReferencesInText(text: string): string {
return text.replace(ACCOUNTED_TOOL_REFERENCE_RE, (name) => toCanonicalToolName(name))
}
export function projectToolReferencesInText(
text: string,
namespace: McpToolNamespace,
canonicalToolNames: ReadonlySet<string>,
): string {
if (namespace === 'gnubok') return text
return text.replace(LEGACY_TOOL_REFERENCE_RE, (name) =>
canonicalToolNames.has(name) ? toPublicToolName(name, namespace) : name
)
}
/**
* Project server-owned MCP payloads into the selected public namespace.
*
* Only exact references to registered tool names are rewritten. Wire-format
* identifiers such as gnubok_sk_ API keys therefore remain unchanged.
*/
export function projectToolReferences<T>(
value: T,
namespace: McpToolNamespace,
canonicalToolNames: ReadonlySet<string>,
): T {
if (namespace === 'gnubok') return value
if (typeof value === 'string') {
return projectToolReferencesInText(value, namespace, canonicalToolNames) as T
}
if (Array.isArray(value)) {
return value.map((item) =>
projectToolReferences(item, namespace, canonicalToolNames)
) as T
}
if (value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
key,
projectToolReferences(item, namespace, canonicalToolNames),
])
) as T
}
return value
}
@@ -11,6 +11,7 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { NotificationType } from '@/types'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { sendNotificationToUser } from './notification-sender'
import {
createTaxDeadlinePayload,
@@ -233,39 +234,115 @@ export async function sendMissingUnderlagNotifications(
let sent = 0
let skipped = 0
// Get all users who have posted entries with source types that need docs
const { data: entries } = await supabase
.from('journal_entries')
.select('id, user_id')
.eq('status', 'posted')
.in('source_type', NEEDS_ATTACHMENT_SOURCE_TYPES)
// Get all users who have posted entries with source types that need docs.
// This is a GLOBAL cron over every company, so each read below must page
// past PostgREST's 1000-row cap: a truncated read here would under-count
// candidates, and a truncated docs/reference read would over-count missing
// underlag, producing false "saknade underlag" notifications.
const entries = await fetchAllRows<{ id: string; user_id: string }>(({ from, to }) =>
supabase
.from('journal_entries')
.select('id, user_id')
.eq('status', 'posted')
.in('source_type', NEEDS_ATTACHMENT_SOURCE_TYPES)
.order('id')
.range(from, to)
)
if (!entries || entries.length === 0) {
if (entries.length === 0) {
return { sent: 0, skipped: 0 }
}
// Get all document_attachments linked to journal entries
const { data: attachments } = await supabase
.from('document_attachments')
.select('journal_entry_id')
.eq('is_current_version', true)
.not('journal_entry_id', 'is', null)
const entriesWithDocs = new Set(
(attachments || []).map((a) => a.journal_entry_id)
const attachments = await fetchAllRows<{ journal_entry_id: string | null }>(({ from, to }) =>
supabase
.from('document_attachments')
.select('journal_entry_id')
.eq('is_current_version', true)
.not('journal_entry_id', 'is', null)
.order('id')
.range(from, to)
)
const entriesWithDocs = new Set(attachments.map((a) => a.journal_entry_id))
// BFL 5 kap 7 § hänvisning: entries referenced by a supplier invoice whose
// document is retained and anchored to a journal entry count as covered
// (mirrors the verifikat_without_documents RPC): typically the payment
// verifikat, whose invoice document hangs on the registration verifikat.
const siRefs = await fetchAllRows<{
registration_journal_entry_id: string | null
payment_journal_entry_id: string | null
document: { journal_entry_id: string | null } | null
}>(({ from, to }) =>
supabase
.from('supplier_invoices')
.select(
'registration_journal_entry_id, payment_journal_entry_id, document:document_attachments(journal_entry_id)'
)
.not('document_id', 'is', null)
.order('id')
.range(from, to) as unknown as PromiseLike<{
data: {
registration_journal_entry_id: string | null
payment_journal_entry_id: string | null
document: { journal_entry_id: string | null } | null
}[] | null
error: { message: string } | null
}>
)
for (const si of siRefs) {
if (!si.document?.journal_entry_id) continue // unanchored: not underlag
if (si.registration_journal_entry_id) entriesWithDocs.add(si.registration_journal_entry_id)
if (si.payment_journal_entry_id) entriesWithDocs.add(si.payment_journal_entry_id)
}
const sipRefs = await fetchAllRows<{
journal_entry_id: string | null
supplier_invoice: {
document_id: string | null
document: { journal_entry_id: string | null } | null
} | null
}>(({ from, to }) =>
supabase
.from('supplier_invoice_payments')
.select(
'journal_entry_id, supplier_invoice:supplier_invoices(document_id, document:document_attachments(journal_entry_id))'
)
.not('journal_entry_id', 'is', null)
.order('id')
.range(from, to) as unknown as PromiseLike<{
data: {
journal_entry_id: string | null
supplier_invoice: {
document_id: string | null
document: { journal_entry_id: string | null } | null
} | null
}[] | null
error: { message: string } | null
}>
)
for (const sip of sipRefs) {
if (sip.journal_entry_id && sip.supplier_invoice?.document?.journal_entry_id) {
entriesWithDocs.add(sip.journal_entry_id)
}
}
// Entries the user has explicitly flagged as "no underlag required" (bank
// fees, interest, internal transfers, salary, tax payments). Treated as
// satisfied so we don't nag the user about them.
const { data: exempted } = await supabase
.from('journal_entry_no_doc_required')
.select('journal_entry_id')
const exemptedEntries = new Set(
(exempted || []).map((e) => e.journal_entry_id)
const exempted = await fetchAllRows<{ journal_entry_id: string }>(({ from, to }) =>
supabase
.from('journal_entry_no_doc_required')
.select('journal_entry_id')
.order('journal_entry_id')
.range(from, to)
)
const exemptedEntries = new Set(exempted.map((e) => e.journal_entry_id))
// Group missing counts by user
const userMissingCounts = new Map<string, number>()
for (const entry of entries) {
@@ -90,7 +90,7 @@ export default function StripeSettingsPanel() {
}
toast({ title: t('connect_failed_title'), description: message, variant: 'destructive' })
}
router.replace('/settings/payments')
router.replace('/import?mode=stripe')
})
return () => { cancelled = true }
}, [searchParams, router, toast, t])
+1 -1
View File
@@ -53,7 +53,7 @@ export const stripeExtension: Extension = {
settingsPanel: {
label: 'Betalningar (Stripe)',
path: '/settings/payments',
path: '/import?mode=stripe',
},
// Core-callable services, resolved via the extension registry (core never
@@ -52,7 +52,8 @@ const mockDoc: TICCompanyDocument = {
{ nameOrIdentifier: 'Test AB', companyNamingType: 'name' },
],
legalEntityType: 'AB',
registrationDate: 0,
// 2026-02-02 in Unix seconds (TIC's native unit; the route converts to ms)
registrationDate: Math.floor(Date.UTC(2026, 1, 2) / 1000),
mostRecentRegisteredAddress: {
streetAddress: 'Storgatan 1',
postalCode: '111 22',
@@ -114,6 +115,28 @@ describe('TIC lookup route', () => {
expect(data.email).toBe('info@test.se')
expect(data.phone).toBe('08-1234567')
expect(data.fiscalYear).toEqual({ startMonthDay: '01-01', endMonthDay: '12-31' })
expect(data.registrationDate).toBe(Date.UTC(2026, 1, 2))
})
it('converts registrationDate from Unix seconds to a millisecond epoch', async () => {
mockSearch.mockResolvedValue(mockDoc)
const res = await lookupHandler(makeRequest('556036-0793'))
const { data } = await res.json()
// Regression: fed raw seconds into `new Date()`, a 2026 registration
// rendered as 1970-01-21 in onboarding's fiscal-year step.
expect(new Date(data.registrationDate).toISOString().slice(0, 10)).toBe('2026-02-02')
})
it('returns registrationDate null when the doc lacks one', async () => {
mockSearch.mockResolvedValue({
...mockDoc,
registrationDate: undefined as unknown as number,
})
const res = await lookupHandler(makeRequest('556036-0793'))
const { data } = await res.json()
expect(data.registrationDate).toBeNull()
})
it('does NOT fan out to Phase 2 endpoints', async () => {
@@ -70,7 +70,8 @@ const mockDoc: TICCompanyDocument = {
{ nameOrIdentifier: 'Test AB', companyNamingType: 'name' },
],
legalEntityType: 'AB',
registrationDate: 946684800000,
// 2000-01-01 in Unix seconds (TIC's native unit; the route converts to ms)
registrationDate: 946684800,
mostRecentPurpose: 'Software development',
mostRecentRegisteredAddress: {
streetAddress: 'Storgatan 1',
@@ -231,6 +232,8 @@ describe('TIC profile route', () => {
expect(data.orgNumber).toBe('5560360793')
expect(data.companyName).toBe('Test AB')
expect(data.legalEntityType).toBe('AB')
// Converted from the doc's Unix seconds to a millisecond epoch.
expect(data.registrationDate).toBe(946684800000)
expect(data.activityStatus).toBe('isActive')
expect(data.purpose).toBe('Software development')
expect(data.address).toEqual({
+12 -2
View File
@@ -226,6 +226,16 @@ function deriveFiscalYearMonthDay(
return { startMonthDay, endMonthDay }
}
// The search doc's registrationDate is a Unix timestamp in seconds (same
// unit as periodStart/periodEnd above), but the app-facing contract
// (CompanyLookupResult / TICCompanyProfile) is a millisecond epoch:
// consumers feed it straight into `new Date()`. Skipping this conversion
// is how 2026 registrations rendered as "21 jan 1970" in onboarding.
function registrationDateToMs(unixSeconds: number | null | undefined): number | null {
if (unixSeconds == null || !Number.isFinite(unixSeconds)) return null
return unixSeconds * 1000
}
function handleTicError(
error: unknown,
log: { error: (msg: string, meta?: unknown) => void } | Console,
@@ -398,7 +408,7 @@ export const ticExtension: Extension = {
sniCodes,
fiscalYear,
legalEntityType: doc.legalEntityType ?? null,
registrationDate: doc.registrationDate ?? null,
registrationDate: registrationDateToMs(doc.registrationDate),
}
return NextResponse.json({ data: result })
@@ -719,7 +729,7 @@ export const ticExtension: Extension = {
orgNumber: doc.registrationNumber,
companyName,
legalEntityType: doc.legalEntityType,
registrationDate: doc.registrationDate,
registrationDate: registrationDateToMs(doc.registrationDate) ?? 0,
activityStatus: isCeasedProfile ? 'ceased' : (doc.activityStatus ?? null),
purpose,
address: doc.mostRecentRegisteredAddress