feat(onboarding): company setup from the conversation and POST /api/v1/companies (#1814 PR 3) (#1864)
* feat(onboarding): company setup from the conversation and POST /api/v1/companies Third PR of agent-first onboarding (#1814). Once connected, the agent can now set up a company end to end without the web wizard, and partner platforms can provision companies over REST. - create_company_for_user: service-role-only SECURITY DEFINER twin of create_company_with_owner taking the owner explicitly (service clients have no auth.uid()). pg-real test covers creation, role gating, unknown owner and foreign team. - lib/company/create-company.ts: the wizard's creation sequence (org number, TIC snapshot, BAS chart, settings, first fiscal period, tax deadlines, rollback) extracted into createCompanyCore; the Server Action delegates to it, behaviour unchanged. - lib/company/onboarding-input.ts: one Zod schema + planner for the agent/API paths; a VAT-registered company without moms_period is refused (a missing period silently yields zero VAT deadlines). - MCP: gnubok_create_company (two-phase: preview, then confirm=true; companies:write, company-independent), gnubok_connect_bank and gnubok_connect_skatteverket (status + the browser link, gated on bank_sync / skatteverket, search-only in the catalog), the "onboarding" skill, and initialize instructions pointing at it. - Consent page pre-ticks companies:write for an account with no company yet, so the setup does not dead-end on insufficient scope after signup. - POST /api/v1/companies (companies:write, dry-run aware) on the same core; scope map, registry, spec snapshot and the generated API skill updated. - tools/list payload ceiling raised 59.95K -> 60.4K for the one new default-catalog tool (documented in the guard). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6 * fix(onboarding): explicit f_skatt, org number when VAT-registered, EF first year ends 31 Dec Review findings on #1864 (Swedish compliance review): - f_skatt is required, never defaulted to approved (SE-R-005 risk). - org_number is required when vat_registered: the invoice momsregistreringsnummer derives from it (ML 17 kap 24 §). - An enskild firma's first fiscal year must end on 31 December and its start month is forced to 1 even with first_fiscal_year set, mirroring the wizard's own rule text (BFL 3 kap. 1 §). - POST /api/v1/companies no longer claims Idempotency-Key support (the wrapper only honours it on company-scoped routes). - pg-real: createCompanyCore's chart seed runs under the real service_role, which the unit tests could not prove. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6 * test(pg): starter chart has 41 accounts, assert non-empty The service_role chart-seed proof passed the part that mattered (no 42501 from seed_chart_of_accounts) and failed on a wrong row-count guess: the seeded chart is a curated starter set, not the full BAS list. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6 * fix(migrations): move create_company_for_user to 20260825120000 main gained 20260824170000_bulk_book_transactions_service_actor.sql with the same version while this branch was open; two files on one version abort every Supabase branch apply and the prod auto-apply. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6 * chore(api): refresh spec snapshot and generated skill after rebasing onto main Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6 * fix(mcp): flat create_company result, refuse localhost connect links, test hygiene CodeRabbit on #1864: the confirmed-create result was wrapped in the { data, next } envelope while its outputSchema promised top-level fields; it now returns the fields with next as a sibling. The two connect-link tools refuse to build a link when NEXT_PUBLIC_APP_URL is unset instead of handing a remote user a localhost URL. Tests clear mocks and the event bus in beforeEach. Not changed: the rollback already survives user_preferences.active_company_id (that FK is ON DELETE SET NULL since 20260331010000), and v1 error details stay in the surface's English developer convention. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
c121d27996
commit
31e0cd6e05
@@ -0,0 +1,100 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { MCP_TOOL_CAPABILITY_MAP } from '@/lib/entitlements/keys'
|
||||
import { tools } from '../server'
|
||||
|
||||
// Onboarding connect-link tools (issue #1814 PR 3): status + the browser link
|
||||
// the user opens. Both flows need a cookie session and BankID in a browser,
|
||||
// so the tools never try to drive them.
|
||||
|
||||
const COMPANY_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const bankTool = tools.find((t) => t.name === 'gnubok_connect_bank')!
|
||||
const skvTool = tools.find((t) => t.name === 'gnubok_connect_skatteverket')!
|
||||
|
||||
function listClient(rows: unknown[] | null, error: unknown = null) {
|
||||
const chain: Record<string, ReturnType<typeof vi.fn>> = {
|
||||
select: vi.fn(() => chain),
|
||||
eq: vi.fn(() => chain),
|
||||
in: vi.fn(() => chain),
|
||||
order: vi.fn(() => chain),
|
||||
limit: vi.fn(() => chain),
|
||||
maybeSingle: vi.fn().mockResolvedValue({ data: rows?.[0] ?? null, error }),
|
||||
then: (resolve: (v: unknown) => void) => resolve({ data: rows, error }),
|
||||
}
|
||||
return { from: vi.fn(() => chain), chain }
|
||||
}
|
||||
|
||||
describe('onboarding connect-link tools', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.example.test')
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('are read-only companies:read tools gated on the capability their link needs', () => {
|
||||
expect(TOOL_SCOPE_MAP.gnubok_connect_bank).toBe('companies:read')
|
||||
expect(TOOL_SCOPE_MAP.gnubok_connect_skatteverket).toBe('companies:read')
|
||||
expect(MCP_TOOL_CAPABILITY_MAP.gnubok_connect_bank).toBe('bank_sync')
|
||||
expect(MCP_TOOL_CAPABILITY_MAP.gnubok_connect_skatteverket).toBe('skatteverket')
|
||||
expect(bankTool.annotations.readOnlyHint).toBe(true)
|
||||
expect(skvTool.annotations.readOnlyHint).toBe(true)
|
||||
})
|
||||
|
||||
it('bank: reports no connection and hands out the PSD2 import link', async () => {
|
||||
const { from, chain } = listClient([])
|
||||
const result = (await bankTool.execute({}, COMPANY_ID, 'user-1', { from } as never)) as Record<string, unknown>
|
||||
expect(result.connected).toBe(false)
|
||||
expect(result.connect_url).toBe('https://app.example.test/import?mode=psd2')
|
||||
expect(chain.eq).toHaveBeenCalledWith('company_id', COMPANY_ID)
|
||||
})
|
||||
|
||||
it('bank: reports an active connection', async () => {
|
||||
const { from } = listClient([
|
||||
{ id: 'c1', bank_name: 'Swedbank', status: 'active', created_at: '2026-08-01T00:00:00Z' },
|
||||
])
|
||||
const result = (await bankTool.execute({}, COMPANY_ID, 'user-1', { from } as never)) as Record<string, unknown>
|
||||
expect(result.connected).toBe(true)
|
||||
expect(result.connections).toEqual([
|
||||
{ connection_id: 'c1', bank: 'Swedbank', status: 'active', since: '2026-08-01T00:00:00Z' },
|
||||
])
|
||||
})
|
||||
|
||||
it('skatteverket: hands out the authorize link when enabled and not connected', async () => {
|
||||
vi.stubEnv('SKATTEVERKET_ENABLED', 'true')
|
||||
const { from } = listClient([])
|
||||
const result = (await skvTool.execute({}, COMPANY_ID, 'user-1', { from } as never)) as Record<string, unknown>
|
||||
expect(result.available).toBe(true)
|
||||
expect(result.connected).toBe(false)
|
||||
expect(result.connect_url).toBe(
|
||||
'https://app.example.test/api/extensions/ext/skatteverket/authorize?return_to=%2F'
|
||||
)
|
||||
})
|
||||
|
||||
it('skatteverket: reports connected with the token expiry', async () => {
|
||||
vi.stubEnv('SKATTEVERKET_ENABLED', 'true')
|
||||
const { from } = listClient([{ expires_at: '2026-12-01T00:00:00Z' }])
|
||||
const result = (await skvTool.execute({}, COMPANY_ID, 'user-1', { from } as never)) as Record<string, unknown>
|
||||
expect(result.connected).toBe(true)
|
||||
expect(result.token_expires_at).toBe('2026-12-01T00:00:00Z')
|
||||
})
|
||||
|
||||
it('refuses to hand out a link when NEXT_PUBLIC_APP_URL is not configured', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', '')
|
||||
const { from } = listClient([])
|
||||
await expect(bankTool.execute({}, COMPANY_ID, 'user-1', { from } as never)).rejects.toMatchObject({
|
||||
code: 'INTERNAL_ERROR',
|
||||
})
|
||||
})
|
||||
|
||||
it('skatteverket: says so when the integration is disabled on the installation', async () => {
|
||||
vi.stubEnv('SKATTEVERKET_ENABLED', 'false')
|
||||
const { from } = listClient([])
|
||||
const result = (await skvTool.execute({}, COMPANY_ID, 'user-1', { from } as never)) as Record<string, unknown>
|
||||
expect(result.available).toBe(false)
|
||||
expect(result.connect_url).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createCompanyCore: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/create-company', () => ({
|
||||
createCompanyCore: (...args: unknown[]) => mocks.createCompanyCore(...args),
|
||||
}))
|
||||
|
||||
import { tools } from '../server'
|
||||
import { isCompanyDependentTool } from '../company-routing'
|
||||
|
||||
const tool = tools.find((t) => t.name === 'gnubok_create_company')!
|
||||
const TEAM_ID = '44444444-4444-4444-8444-444444444444'
|
||||
const COMPANY_ID = '55555555-5555-4555-8555-555555555555'
|
||||
|
||||
function supabaseWithTeam(teamId: string | null) {
|
||||
const chain: Record<string, ReturnType<typeof vi.fn>> = {
|
||||
select: vi.fn(() => chain),
|
||||
eq: vi.fn(() => chain),
|
||||
order: vi.fn(() => chain),
|
||||
limit: vi.fn(() => chain),
|
||||
maybeSingle: vi.fn().mockResolvedValue({ data: teamId ? { team_id: teamId } : null, error: null }),
|
||||
}
|
||||
return {
|
||||
from: vi.fn(() => chain),
|
||||
rpc: vi.fn().mockResolvedValue({ data: COMPANY_ID, error: null }),
|
||||
}
|
||||
}
|
||||
|
||||
const setup = {
|
||||
name: 'Testbolaget AB',
|
||||
entity_type: 'aktiebolag',
|
||||
org_number: '556000-0001',
|
||||
vat_registered: true,
|
||||
moms_period: 'quarterly',
|
||||
accounting_method: 'accrual',
|
||||
f_skatt: true,
|
||||
}
|
||||
|
||||
describe('gnubok_create_company', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
it('is a companies:write, company-independent write tool', () => {
|
||||
expect(tool).toBeDefined()
|
||||
expect(TOOL_SCOPE_MAP.gnubok_create_company).toBe('companies:write')
|
||||
expect(isCompanyDependentTool('gnubok_create_company')).toBe(false)
|
||||
expect(tool.annotations.readOnlyHint).toBe(false)
|
||||
expect(tool.annotations.destructiveHint).toBe(false)
|
||||
})
|
||||
|
||||
it('previews without creating when confirm is not true', async () => {
|
||||
const supabase = supabaseWithTeam(TEAM_ID)
|
||||
const result = (await tool.execute(setup, '', 'user-1', supabase as never)) as Record<string, unknown>
|
||||
|
||||
expect(result.created).toBe(false)
|
||||
expect(result.requires_confirmation).toBe(true)
|
||||
const preview = result.preview as Record<string, unknown>
|
||||
expect(preview.org_number).toBe('5560000001')
|
||||
expect(preview.vat_number).toBe('SE556000000101')
|
||||
expect(preview.team_id).toBe(TEAM_ID)
|
||||
expect(preview.fiscal_period).toMatchObject({ name: expect.stringContaining('Räkenskapsår') })
|
||||
expect(mocks.createCompanyCore).not.toHaveBeenCalled()
|
||||
expect(supabase.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates through the service-role RPC for the key user with confirm=true', async () => {
|
||||
const supabase = supabaseWithTeam(TEAM_ID)
|
||||
mocks.createCompanyCore.mockImplementation(
|
||||
async (_client: unknown, _input: unknown, createRow: () => Promise<{ data: unknown; error: unknown }>) => {
|
||||
const { data } = await createRow()
|
||||
return { companyId: data as string }
|
||||
}
|
||||
)
|
||||
|
||||
const result = (await tool.execute(
|
||||
{ ...setup, confirm: true },
|
||||
'',
|
||||
'user-1',
|
||||
supabase as never
|
||||
)) as Record<string, unknown>
|
||||
|
||||
expect(result.created).toBe(true)
|
||||
expect(result.company_id).toBe(COMPANY_ID)
|
||||
expect(supabase.rpc).toHaveBeenCalledWith('create_company_for_user', {
|
||||
p_user_id: 'user-1',
|
||||
p_name: 'Testbolaget AB',
|
||||
p_entity_type: 'aktiebolag',
|
||||
p_team_id: TEAM_ID,
|
||||
})
|
||||
const [, input] = mocks.createCompanyCore.mock.calls[0] as [unknown, Record<string, unknown>]
|
||||
expect(input.settings).toMatchObject({ moms_period: 'quarterly', vat_registered: true, company_name: 'Testbolaget AB' })
|
||||
expect((result.next as Record<string, unknown>).tool).toBe('gnubok_load_skill')
|
||||
})
|
||||
|
||||
it('refuses a VAT-registered company without a moms period before touching the database', async () => {
|
||||
const supabase = supabaseWithTeam(TEAM_ID)
|
||||
await expect(
|
||||
tool.execute({ ...setup, moms_period: undefined, confirm: true }, '', 'user-1', supabase as never)
|
||||
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', message: expect.stringContaining('moms_period') })
|
||||
expect(supabase.rpc).not.toHaveBeenCalled()
|
||||
expect(mocks.createCompanyCore).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses an explicit team_id over the default team', async () => {
|
||||
const supabase = supabaseWithTeam(TEAM_ID)
|
||||
const other = '66666666-6666-4666-8666-666666666666'
|
||||
const result = (await tool.execute({ ...setup, team_id: other }, '', 'user-1', supabase as never)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
expect((result.preview as Record<string, unknown>).team_id).toBe(other)
|
||||
expect(supabase.from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces a creation failure as a coded error', async () => {
|
||||
const supabase = supabaseWithTeam(null)
|
||||
mocks.createCompanyCore.mockResolvedValue({ error: 'Kunde inte skapa kontoplan. Försök igen.' })
|
||||
await expect(
|
||||
tool.execute({ ...setup, confirm: true }, '', 'user-1', supabase as never)
|
||||
).rejects.toMatchObject({ code: 'COMPANY_CREATE_FAILED' })
|
||||
})
|
||||
})
|
||||
@@ -209,9 +209,16 @@ describe('tools/list payload size guard', () => {
|
||||
// start instead of mid-task. The runtime block is emitted only for
|
||||
// companies with a connection; this cost is the outputSchema contract
|
||||
// (~140 tokens), already trimmed to two short description strings.
|
||||
// * 60.2K to 60.7K with gnubok_create_company (issue #1814 PR 3): a
|
||||
// default-catalog tool by necessity, since a client that has not
|
||||
// connected yet can only call what tools/list shows and this is the
|
||||
// first protected call of agent-driven onboarding. Its contract was
|
||||
// trimmed to bare property names first (the two connect-link tools
|
||||
// are search-only); headroom before the change was ~0 after the skatteverket_connection bump, so even the
|
||||
// bare contract crossed by ~420.
|
||||
// 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(60_200)
|
||||
expect(approxTokens).toBeLessThan(60_700)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,6 +10,8 @@ const COMPANY_INDEPENDENT_TOOLS = new Set([
|
||||
'gnubok_list_skills',
|
||||
'gnubok_load_skill',
|
||||
'gnubok_list_companies',
|
||||
// Creates the company: by definition it runs before one exists.
|
||||
'gnubok_create_company',
|
||||
])
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,9 @@ import {
|
||||
type ApiKeyScope,
|
||||
} from '@/lib/auth/api-keys'
|
||||
import { checkRateLimit } from '@/lib/auth/rate-limit-http'
|
||||
import { getCanonicalBaseUrl } from '@/lib/api/v1/base-url'
|
||||
import { createCompanyCore } from '@/lib/company/create-company'
|
||||
import { CompanySetupSchema, planCompanySetup } from '@/lib/company/onboarding-input'
|
||||
import {
|
||||
ANONYMOUS_METHODS,
|
||||
ANONYMOUS_RATE_LIMIT,
|
||||
@@ -157,7 +160,7 @@ import {
|
||||
hashRequest,
|
||||
IdempotencyKeyReuseError,
|
||||
} from '@/lib/api/idempotency'
|
||||
import { toToolError, type NextActionHint } from './tool-result'
|
||||
import { toToolError, withNext, type NextActionHint } from './tool-result'
|
||||
import {
|
||||
addCompanyToNextHint,
|
||||
addCompanyToTopLevelNext,
|
||||
@@ -1433,6 +1436,42 @@ export function isDefaultCatalogTool(tool: { catalogVisibility?: 'default' | 'se
|
||||
return tool.catalogVisibility !== 'search'
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute origin for links the user opens in a browser. A deployment
|
||||
* without NEXT_PUBLIC_APP_URL falls back to localhost in getCanonicalBaseUrl,
|
||||
* which would hand a remote user an unusable link: refuse instead.
|
||||
*/
|
||||
function connectLinkBaseUrl(): string {
|
||||
if (!process.env.NEXT_PUBLIC_APP_URL) {
|
||||
throw Object.assign(
|
||||
new Error('NEXT_PUBLIC_APP_URL is not configured on this installation; cannot build a connect link'),
|
||||
{ code: 'INTERNAL_ERROR' }
|
||||
)
|
||||
}
|
||||
return getCanonicalBaseUrl()
|
||||
}
|
||||
|
||||
/**
|
||||
* The team a company created through the API/MCP path attaches to when the
|
||||
* caller does not name one: the user's first (usually the silent personal)
|
||||
* team, mirroring what the web wizard passes. null when the user has no
|
||||
* team at all; create_company_for_user then leaves team_id NULL.
|
||||
*/
|
||||
async function defaultTeamForUser(supabase: SupabaseClient, userId: string): Promise<string | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('team_members')
|
||||
.select('team_id')
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
if (error) {
|
||||
log.warn('default team lookup failed', { error: error.message })
|
||||
return null
|
||||
}
|
||||
return (data?.team_id as string | undefined) ?? null
|
||||
}
|
||||
|
||||
function paginatedSchema(itemsKey: string, itemSchema: Record<string, unknown> = { type: 'object' }) {
|
||||
return {
|
||||
type: 'object',
|
||||
@@ -2902,6 +2941,232 @@ export const tools: McpTool[] = [
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_create_company',
|
||||
title: 'Create Company',
|
||||
description:
|
||||
'Create a NEW company for the connected user, set up for bookkeeping (chart, settings, first fiscal period, tax deadlines; 30-day trial). Preview first (no confirm), read it back, then confirm=true. Ask, never assume: form, orgnr, VAT + moms period, method. Skill: onboarding.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
name: { type: 'string', minLength: 1, maxLength: 200 },
|
||||
entity_type: { type: 'string', enum: ['enskild_firma', 'aktiebolag'] },
|
||||
org_number: { type: 'string', description: '10 digits; required when VAT-registered' },
|
||||
vat_registered: { type: 'boolean' },
|
||||
moms_period: { type: 'string', enum: ['monthly', 'quarterly', 'yearly'], description: 'Required when vat_registered' },
|
||||
accounting_method: { type: 'string', enum: ['accrual', 'cash'] },
|
||||
f_skatt: { type: 'boolean' },
|
||||
fiscal_year_start_month: { type: 'integer', minimum: 1, maximum: 12 },
|
||||
first_fiscal_year: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: { start: { type: 'string' }, end: { type: 'string' } },
|
||||
required: ['start', 'end'],
|
||||
description: 'YYYY-MM-DD; first fiscal year only',
|
||||
},
|
||||
address_line1: { type: 'string' },
|
||||
postal_code: { type: 'string' },
|
||||
city: { type: 'string' },
|
||||
team_id: { type: 'string', format: 'uuid' },
|
||||
confirm: { type: 'boolean', description: 'true creates; omitted = preview' },
|
||||
},
|
||||
required: ['name', 'entity_type', 'vat_registered', 'accounting_method', 'f_skatt'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
created: { type: 'boolean' },
|
||||
requires_confirmation: { type: 'boolean' },
|
||||
company_id: { type: 'string' },
|
||||
preview: { type: 'object' },
|
||||
next: { type: 'object' },
|
||||
message: { type: 'string' },
|
||||
},
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, _companyId, userId, supabase) {
|
||||
const { confirm, ...setup } = args
|
||||
const parsed = CompanySetupSchema.safeParse(setup)
|
||||
if (!parsed.success) {
|
||||
const detail = parsed.error.issues
|
||||
.map((issue) => `${issue.path.join('.') || 'input'}: ${issue.message}`)
|
||||
.join('; ')
|
||||
throw Object.assign(new Error(`Invalid company setup: ${detail}`), { code: 'VALIDATION_ERROR' })
|
||||
}
|
||||
const plan = planCompanySetup(parsed.data)
|
||||
if (!plan.ok) {
|
||||
throw Object.assign(new Error(`Invalid fiscal period: ${plan.error}`), { code: 'VALIDATION_ERROR' })
|
||||
}
|
||||
|
||||
const teamId = parsed.data.team_id ?? (await defaultTeamForUser(supabase, userId))
|
||||
const preview = {
|
||||
name: parsed.data.name,
|
||||
entity_type: parsed.data.entity_type,
|
||||
org_number: (plan.input.settings.org_number as string | null) ?? null,
|
||||
vat_registered: parsed.data.vat_registered,
|
||||
vat_number: (plan.input.settings.vat_number as string | null) ?? null,
|
||||
moms_period: parsed.data.vat_registered ? parsed.data.moms_period ?? null : null,
|
||||
accounting_method: parsed.data.accounting_method,
|
||||
f_skatt: parsed.data.f_skatt,
|
||||
fiscal_period: plan.fiscalPeriod,
|
||||
team_id: teamId,
|
||||
}
|
||||
|
||||
if (confirm !== true) {
|
||||
return {
|
||||
created: false,
|
||||
requires_confirmation: true,
|
||||
preview,
|
||||
message:
|
||||
'Nothing was created. Read the preview back to the user (especially the fiscal period dates and the VAT setup), then call gnubok_create_company again with the same arguments and confirm=true.',
|
||||
}
|
||||
}
|
||||
|
||||
const result = await createCompanyCore(supabase, plan.input, () =>
|
||||
supabase.rpc('create_company_for_user', {
|
||||
p_user_id: userId,
|
||||
p_name: parsed.data.name,
|
||||
p_entity_type: parsed.data.entity_type,
|
||||
p_team_id: teamId,
|
||||
})
|
||||
)
|
||||
if (result.error !== undefined) {
|
||||
const code = result.error === 'org_number_invalid' ? 'VALIDATION_ERROR' : 'COMPANY_CREATE_FAILED'
|
||||
throw Object.assign(new Error(result.error), { code })
|
||||
}
|
||||
|
||||
return {
|
||||
created: true,
|
||||
company_id: result.companyId,
|
||||
...preview,
|
||||
trial: 'A 30-day trial with every paid capability (bank sync, Skatteverket, AI, e-mail) is active from now.',
|
||||
message:
|
||||
'Company created and ready for bookkeeping. This connection uses it automatically from the next call. Remaining setup: bank connection, Skatteverket connection, and the first transactions.',
|
||||
next: {
|
||||
description: 'Load the onboarding skill for the remaining setup steps (bank, Skatteverket, first transactions).',
|
||||
tool: 'gnubok_load_skill',
|
||||
args: { slug: 'onboarding' },
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_connect_bank',
|
||||
title: 'Connect Bank',
|
||||
description:
|
||||
'Bank connection status plus the browser link where the user connects a bank (PSD2, BankID consent; must be logged in to Accounted there). Use after gnubok_create_company or when transactions are missing because no bank is connected.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
connected: { type: 'boolean' },
|
||||
connections: { type: 'array', items: { type: 'object' } },
|
||||
connect_url: { type: 'string' },
|
||||
instructions: { type: 'string' },
|
||||
},
|
||||
required: ['connected', 'connections', 'connect_url', 'instructions'],
|
||||
},
|
||||
catalogVisibility: 'search',
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(_args, companyId, _userId, supabase) {
|
||||
const { data, error } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('id, bank_name, status, created_at')
|
||||
.eq('company_id', companyId)
|
||||
.in('status', ['pending', 'pending_selection', 'active', 'expired', 'error'])
|
||||
.order('created_at', { ascending: false })
|
||||
if (error) throw error
|
||||
const connections = (data ?? []) as Array<{ id: string; bank_name: string | null; status: string; created_at: string }>
|
||||
const active = connections.filter((c) => c.status === 'active')
|
||||
const connectUrl = `${connectLinkBaseUrl()}/import?mode=psd2`
|
||||
return {
|
||||
connected: active.length > 0,
|
||||
connections: connections.map((c) => ({
|
||||
connection_id: c.id,
|
||||
bank: c.bank_name,
|
||||
status: c.status,
|
||||
since: c.created_at,
|
||||
})),
|
||||
connect_url: connectUrl,
|
||||
instructions:
|
||||
active.length > 0
|
||||
? 'At least one bank is connected and syncing. To add another bank, give the user the connect_url.'
|
||||
: 'Give the user the connect_url to open in their browser (they must be logged in to Accounted there). They pick their bank and approve with BankID; consent lasts up to 180 days and the first transactions arrive within a minute. Tell them to come back here when done, then continue with gnubok_list_uncategorized_transactions.',
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_connect_skatteverket',
|
||||
title: 'Connect Skatteverket',
|
||||
description:
|
||||
'Skatteverket connection status plus the browser link where the user authorises Accounted (BankID as firmatecknare; logged in to Accounted there). Enables skattekonto sync and filing of moms/AGI. Use after gnubok_create_company or when a filing tool reports no connection.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
available: { type: 'boolean' },
|
||||
connected: { type: 'boolean' },
|
||||
token_expires_at: { type: ['string', 'null'] },
|
||||
connect_url: { type: ['string', 'null'] },
|
||||
instructions: { type: 'string' },
|
||||
},
|
||||
required: ['available', 'connected', 'token_expires_at', 'connect_url', 'instructions'],
|
||||
},
|
||||
catalogVisibility: 'search',
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(_args, companyId, _userId, supabase) {
|
||||
const enabled = process.env.SKATTEVERKET_ENABLED === 'true'
|
||||
const { data, error } = await supabase
|
||||
.from('skatteverket_tokens')
|
||||
.select('expires_at')
|
||||
.eq('company_id', companyId)
|
||||
.order('expires_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
if (error) throw error
|
||||
const token = data as { expires_at: string } | null
|
||||
const connected = Boolean(token)
|
||||
const connectUrl = `${connectLinkBaseUrl()}/api/extensions/ext/skatteverket/authorize?return_to=%2F`
|
||||
return {
|
||||
available: enabled,
|
||||
connected,
|
||||
token_expires_at: token?.expires_at ?? null,
|
||||
connect_url: enabled ? connectUrl : null,
|
||||
instructions: !enabled
|
||||
? 'The Skatteverket integration is not enabled on this installation. Declarations can still be downloaded as files and filed manually at skatteverket.se.'
|
||||
: connected
|
||||
? 'Skatteverket is connected. Skattekonto syncs automatically; momsdeklaration and AGI can be filed from here (each filing stages for approval).'
|
||||
: 'Give the user the connect_url to open in their browser (logged in to Accounted). Skatteverket asks them to identify with BankID as firmatecknare and approve the access; they land back in Accounted afterwards. Tell them to come back here when done.',
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_get_company_settings',
|
||||
title: 'Get Company Settings',
|
||||
@@ -18437,14 +18702,14 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
'',
|
||||
...(isAnonymous
|
||||
? [
|
||||
'NOT CONNECTED YET. Without an account you can call gnubok_search_tools, gnubok_list_skills and gnubok_load_skill. Every other tool needs the user to connect their Accounted account: calling one returns an authentication challenge that your client shows as a Connect prompt. A user who has no account creates one right there (BankID or e-mail, about a minute), and the call is then retried automatically. To start bookkeeping for a company that is not in Accounted yet, call gnubok_list_companies to trigger the connect step, then continue with the setup.',
|
||||
'NOT CONNECTED YET. Without an account you can call gnubok_search_tools, gnubok_list_skills and gnubok_load_skill. Every other tool needs the user to connect their Accounted account: calling one returns an authentication challenge that your client shows as a Connect prompt. A user who has no account creates one right there (BankID or e-mail, about a minute), and the call is then retried automatically. To set up bookkeeping for a company that is not in Accounted yet, load the "onboarding" skill (gnubok_load_skill) and follow it: gnubok_create_company is the first protected call and triggers the connect step.',
|
||||
'',
|
||||
]
|
||||
: []),
|
||||
'Discovery:',
|
||||
'• tools/list returns common tool schemas. Call gnubok_search_tools(query="…") for specialized tools: it ranks all capabilities; pass detail="name"|"summary"|"full" to control payload size.',
|
||||
'• gnubok_get_agent_briefing returns recommended_tools: ordered per-workflow tool loadouts (categorize_month, close_period, invoice_run, vat_declaration, payroll_month). If your harness defers tool loading, batch-load a whole workflow in one call (e.g. Claude Code ToolSearch select:a,b,c) instead of searching cluster by cluster.',
|
||||
`• This connection can work with every non-archived company the API-key user belongs to. Call gnubok_list_companies to discover company_id values. Omit company_id to use the API key default (${companyId ?? 'none yet: this account has no company; it must be created in the web app before company-data tools work'}); when selecting another company, repeat company_id on every company-data call, including approval.`,
|
||||
`• This connection can work with every non-archived company the API-key user belongs to. Call gnubok_list_companies to discover company_id values. Omit company_id to use the API key default (${companyId ?? 'none yet: this account has no company. Create it with gnubok_create_company (preview first, then confirm=true); the "onboarding" skill walks the whole setup'}); when selecting another company, repeat company_id on every company-data call, including approval.`,
|
||||
'• MCP resources use the API key default company. For a selected non-default company, call gnubok_get_agent_briefing with company_id instead of relying on Accounted://company/current or other company-data resources.',
|
||||
'• When the user asks "how do I do X" or you\'re unsure of the correct sequence (month-end close, VAT review, year-end, invoicing, payroll), call gnubok_list_skills first: domain workflows are documented as loadable skills with tool references.',
|
||||
'• When a tool is missing, a description misled you, a result looks wrong, or something worked unusually well, call gnubok_feedback (context + suggestion, optional tool_name). It is read by the product team and has fixed real bugs; include ids and what you expected. Rate-limited 1/min/key, so batch a session\'s findings into one call.',
|
||||
|
||||
@@ -9,6 +9,7 @@ import { bankReconciliationSkill } from './bank-reconciliation'
|
||||
import { kreditfakturaProcessSkill } from './kreditfaktura-process'
|
||||
import { customerOnboardingSkill } from './customer-onboarding'
|
||||
import { reconcileMonthSkill } from './reconcile-month'
|
||||
import { onboardingSkill } from './onboarding'
|
||||
import { loadAtomsAsSkills, loadReferenceById } from './atoms'
|
||||
|
||||
/** Static workflow skills the server ships with. Tier: 'workflow'. */
|
||||
@@ -22,6 +23,7 @@ export const workflowSkills: Skill[] = [
|
||||
kreditfakturaProcessSkill,
|
||||
customerOnboardingSkill,
|
||||
reconcileMonthSkill,
|
||||
onboardingSkill,
|
||||
]
|
||||
|
||||
/** @deprecated Use `workflowSkills` for the static set, or `loadAllSkills(supabase)`
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { Skill } from './types'
|
||||
|
||||
const body = `# Onboarding: set up a company in Accounted from the conversation
|
||||
|
||||
From "my company is not in Accounted yet" to a working ledger without the
|
||||
user opening the web app first. The only browser steps are the ones that
|
||||
legally need a human with BankID: connecting (creating the account),
|
||||
approving the bank consent, and authorising Skatteverket. Everything else
|
||||
happens here.
|
||||
|
||||
## When to use
|
||||
|
||||
- "Sätt upp bokföring för mitt AB / min enskilda firma"
|
||||
- "Jag har precis startat bolag, hur kommer jag igång?"
|
||||
- "Lägg till ett nytt bolag" (an existing user adding a second company)
|
||||
- A byrå/consultant onboarding a new client company (pass \`team_id\`)
|
||||
|
||||
## Step 0: connect
|
||||
|
||||
If this session is not connected yet, the first company-scoped call (for
|
||||
example \`gnubok_create_company\`) returns an authentication challenge that the
|
||||
client shows as a Connect prompt. Tell the user: "Klicka på Connect; har du
|
||||
inget konto skapar du det där (BankID eller e-post), det tar en minut." The
|
||||
call is retried automatically once connected. Do not send the user to the web
|
||||
app to sign up first.
|
||||
|
||||
## Step 1: gather the facts (ask, never assume)
|
||||
|
||||
Collect these before creating anything. The order mirrors the in-app wizard.
|
||||
|
||||
1. **Organisationsnummer** (10 digits). Required when the company is
|
||||
VAT-registered (the momsregistreringsnummer on every invoice derives from
|
||||
it) and strongly recommended otherwise: it drives Skatteverket/SIE exports. An enskild firma's
|
||||
org number is the owner's personnummer; that is fine to store here.
|
||||
2. **Company form**: \`aktiebolag\` or \`enskild_firma\`. Only these two are
|
||||
supported today; HB/KB/förening are not.
|
||||
3. **Company name** as registered.
|
||||
4. **F-skatt**: godkänd för F-skatt? Always ask; the tool refuses to assume it.
|
||||
A brand-new company may still be waiting for Skatteverket's approval (then
|
||||
false).
|
||||
5. **Fiscal year**: for enskild firma always the calendar year (do not ask);
|
||||
its first year may be shorter or up to 18 months but always ends 31 December.
|
||||
For an AB ask whether it is the calendar year or another 12-month period
|
||||
(\`fiscal_year_start_month\`). For a company in its FIRST year ask for the
|
||||
exact first fiscal year start and end (BFL 3 kap.: it may be shorter than
|
||||
12 months or up to 18 months) and pass \`first_fiscal_year\`.
|
||||
6. **VAT**: momsregistrerad? If yes, which period: \`monthly\`, \`quarterly\` or
|
||||
\`yearly\`. This is required when VAT-registered: without it Accounted
|
||||
generates no VAT deadlines at all, silently. If the user does not know,
|
||||
the rule of thumb: turnover under 1 MSEK may report yearly, under 40 MSEK
|
||||
quarterly, above that monthly; Skatteverket's registration decision states
|
||||
the actual period. Never guess it into the tool; ask.
|
||||
7. **Accounting method**: \`accrual\` (faktureringsmetoden) or \`cash\`
|
||||
(kontantmetoden / bokslutsmetoden). Cash is only allowed under 3 MSEK
|
||||
turnover and is common for small enskild firma; AB with invoices usually
|
||||
run accrual.
|
||||
|
||||
## Step 2: preview, confirm, create
|
||||
|
||||
Call \`gnubok_create_company\` WITHOUT \`confirm\` first. Read the preview back
|
||||
to the user in plain Swedish: company form, org number, the fiscal period
|
||||
dates, VAT setup (registered + period), method. Only after an explicit "ja"
|
||||
call it again with the same arguments and \`confirm: true\`.
|
||||
|
||||
What creation does in one step: company + owner membership, BAS chart of
|
||||
accounts for the company form, settings, the first fiscal period, and the
|
||||
automatic tax deadlines (moms, F-skatt, AGI, inkomstdeklaration). The 30-day
|
||||
trial with bank sync, Skatteverket, AI and e-mail starts immediately. This
|
||||
connection uses the new company automatically from the next call; no
|
||||
re-authentication.
|
||||
|
||||
## Step 3: connect the bank
|
||||
|
||||
Call \`gnubok_connect_bank\`. It reports existing connections and returns a
|
||||
\`connect_url\`. The user opens it in a browser where they are logged in to
|
||||
Accounted, picks the bank and approves with BankID (PSD2 consent, up to 180
|
||||
days). Transactions start syncing within a minute. If they prefer not to
|
||||
connect a bank, they can import bank statements as files in the web app
|
||||
instead; do not block on this step.
|
||||
|
||||
## Step 4: connect Skatteverket (optional but recommended)
|
||||
|
||||
Call \`gnubok_connect_skatteverket\`. Same pattern: the user opens the
|
||||
\`connect_url\`, identifies with BankID as firmatecknare at Skatteverket, and
|
||||
lands back in Accounted. This enables skattekonto sync and filing of
|
||||
momsdeklaration and arbetsgivardeklaration from here. Filing is never
|
||||
mandatory through Accounted: every declaration can be downloaded and filed
|
||||
manually.
|
||||
|
||||
## Step 5: first bookkeeping
|
||||
|
||||
Once transactions arrive: \`gnubok_list_uncategorized_transactions\` and the
|
||||
categorize flow (\`gnubok_suggest_categories\`, \`gnubok_categorize_transaction\`,
|
||||
approval). For a company with history in another system, offer the SIE
|
||||
import (\`gnubok_import_sie\` in the search catalog) before categorizing.
|
||||
|
||||
## Tools
|
||||
|
||||
- \`gnubok_create_company\`: preview (no confirm) then create (confirm=true)
|
||||
- \`gnubok_list_companies\`: see which companies this connection can reach
|
||||
- \`gnubok_connect_bank\`: status + connect link for PSD2 bank consent
|
||||
- \`gnubok_connect_skatteverket\`: status + connect link for Skatteverket
|
||||
- \`gnubok_get_agent_briefing\`: the company's settings and state once created
|
||||
- \`gnubok_list_uncategorized_transactions\`: the first real bookkeeping step
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- A VAT-registered company without a moms period is refused on purpose; do
|
||||
not work around it by claiming the company is not VAT-registered.
|
||||
- Do not create a company twice on a retry: check \`gnubok_list_companies\`
|
||||
if a create call was interrupted.
|
||||
- Bookkeeping duty starts when the company exists in Accounted with a fiscal
|
||||
period. Never create a company "to try things out" for a real
|
||||
organisation; use the sandbox in the web app for demos.
|
||||
`
|
||||
|
||||
export const onboardingSkill: Skill = {
|
||||
slug: 'onboarding',
|
||||
name: 'Onboarding: New Company Setup',
|
||||
summary:
|
||||
'Set up a company from the conversation: gather facts, preview and create with gnubok_create_company, then hand out the bank and Skatteverket connect links.',
|
||||
tags: ['onboarding', 'setup', 'company', 'bank', 'skatteverket', 'agent-first'],
|
||||
body,
|
||||
tier: 'workflow',
|
||||
}
|
||||
Reference in New Issue
Block a user