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:
Jakob Wennberg
2026-08-25 12:41:02 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent c121d27996
commit 31e0cd6e05
25 changed files with 1920 additions and 183 deletions
+5
View File
@@ -1212,3 +1212,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-25] WhatsApp instant received-signal = emoji reaction (U+2705) sent from the webhook, not an extra text message: reactions add no chat bubble so the one-combined-ack-per-burst design survives; best-effort and not persisted as an outbound row (cosmetic, like mark-read), gated on the CHAT_ALLOWED_MIME_TYPES allowlist so junk never earns a checkmark.
[2026-08-24] MCP lazy authentication (#1814 PR 2) lists the FULL default tool catalog to anonymous clients and only gates tools/call: the agent has to be able to name a protected tool to trigger the 401 challenge that opens the Connect (and signup) prompt; listing only public tools would hide the trigger. Descriptions are public documentation anyway.
[2026-08-24] Public (pre-auth) MCP tools are the three documentation tools only (search_tools, list_skills, load_skill); org-number lookup stays behind the challenge for now because the TIC lookup lives in another extension and cross-extension imports are forbidden.
[2026-08-24] gnubok_create_company (#1814 PR 3) is a direct write with an explicit two-phase confirm (preview, then confirm=true) instead of a staged pending_operation: pending_operations rows are company-scoped and there is no company to attach the staging to before it exists.
[2026-08-24] gnubok_connect_bank / gnubok_connect_skatteverket hand the user a browser link plus connection status instead of driving the PSD2 or Skatteverket flow from the MCP server: both extension handlers need a cookie session and BankID in a browser, and cross-extension imports are forbidden. The web app's /import?mode=psd2 and the skatteverket authorize route are the links.
[2026-08-24] The MCP consent page pre-ticks companies:write for an account that has no company yet: that account is connecting in order to create a company, and a default that dead-ends on insufficient scope right after signup would be the worse default. Still an untickable checkbox, still bounded by the client's scope ceiling.
[2026-08-25] gnubok_create_company / POST /api/v1/companies require f_skatt explicitly and org_number whenever vat_registered (review findings on #1864): a defaulted F-skatt approval or a VAT-registered company with no momsregistreringsnummer would flow straight into invoices (ML 17 kap 24 §, SE-R-005). Explicit beats convenient on a legal fact.
[2026-08-25] An enskild firma's first fiscal year must end on 31 December in the programmatic setup paths, mirroring the wizard's own rule text: the calendar-year mandate (BFL 3 kap. 1 §) is not lifted by the first-year extension.
@@ -435,6 +435,20 @@ describe('account with no company yet (issue #1814)', () => {
expect(supabase.from).not.toHaveBeenCalled()
})
it('pre-ticks companies:write so the agent can create the company, other writes stay unticked', async () => {
mocks.createClient.mockResolvedValue(buildSupabase({ id: 'user-1', email: 'ny@example.se' }))
const response = await GET(new Request(buildAuthorizeUrl(authorizeParams)))
const html = await response.text()
const companiesWrite = html.match(/<input[^>]*value="companies:write"[^>]*>/)?.[0]
expect(companiesWrite).toBeDefined()
expect(companiesWrite!).toContain('checked')
const transactionsWrite = html.match(/<input[^>]*value="transactions:write"[^>]*>/)?.[0]
expect(transactionsWrite).toBeDefined()
expect(transactionsWrite!).not.toContain('checked')
})
it('POST still issues an authorization code', async () => {
mocks.createClient.mockResolvedValue(buildSupabase({ id: 'user-1', email: 'ny@example.se' }))
+8
View File
@@ -283,6 +283,14 @@ export async function GET(request: Request) {
// instructions"), which is the whole point of the consent step.
const grantCeiling = new Set<ApiKeyScope>(parsed.scopes ?? ALL_SCOPES)
const preChecked = new Set<ApiKeyScope>(parsed.scopes ?? DEFAULT_OAUTH_SCOPES)
// An account with no company is connecting in order to create one
// (issue #1814): pre-tick the one write scope that gnubok_create_company
// needs, so the agent-driven setup does not dead-end on insufficient scope
// right after signup. Still a checkbox the user can untick, and still
// bounded by the client's ceiling.
if (!companyId && grantCeiling.has('companies:write')) {
preChecked.add('companies:write')
}
const scopeCheckboxesHtml = renderScopeCheckboxes(preChecked, grantCeiling)
// Render consent page
@@ -0,0 +1,189 @@
/**
* POST /api/v1/companies (issue #1814 PR 3): programmatic company creation
* for partner provisioning and agents. Same static-route context shape as the
* GET tests (Next.js 16 passes `{ params: undefined }`).
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
})
const mocks = vi.hoisted(() => ({
createCompanyCore: vi.fn(),
}))
vi.mock('@/lib/auth/api-keys', async () => {
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return {
...actual,
validateApiKey: vi.fn(),
createServiceClientNoCookies: vi.fn(),
}
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
})
vi.mock('@/lib/company/create-company', () => ({
createCompanyCore: (...args: unknown[]) => mocks.createCompanyCore(...args),
}))
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { POST as createCompany } from '../route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
const USER_ID = '930abb54-c5ef-4ae0-b274-30fb16e9a295'
const TEAM_ID = '44444444-4444-4444-8444-444444444444'
const COMPANY_ID = '55555555-5555-4555-8555-555555555555'
function makeSupabase(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 }),
}
}
function makeRequest(body: unknown, headers: Record<string, string> = {}): Request {
return new Request('https://x.test/api/v1/companies', {
method: 'POST',
headers: {
Authorization: 'Bearer test-fixture-not-a-real-key',
'Content-Type': 'application/json',
'Idempotency-Key': 'idem-1',
...headers,
},
body: typeof body === 'string' ? body : JSON.stringify(body),
})
}
type PostCtx = Parameters<typeof createCompany>[1]
const staticRouteContext = () => ({ params: undefined } as unknown as PostCtx)
const validBody = {
name: 'Acme AB',
entity_type: 'aktiebolag',
org_number: '5560000001',
vat_registered: true,
moms_period: 'quarterly',
accounting_method: 'accrual',
f_skatt: true,
}
beforeEach(() => {
vi.clearAllMocks()
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: null,
apiKeyId: 'key-1',
apiKeyName: 'Partner key',
scopes: ['companies:write', 'companies:read'],
mode: 'live',
})
mockServiceClient.mockReturnValue(makeSupabase(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 }
}
)
})
describe('POST /api/v1/companies', () => {
it('returns 401 for a missing bearer token', async () => {
const request = new Request('https://x.test/api/v1/companies', {
method: 'POST',
body: JSON.stringify(validBody),
headers: { 'Content-Type': 'application/json' },
})
const res = await createCompany(request, staticRouteContext())
expect(res.status).toBe(401)
})
it('returns 403 without companies:write', async () => {
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: null,
apiKeyId: 'key-1',
apiKeyName: 'Read key',
scopes: ['companies:read'],
mode: 'live',
})
const res = await createCompany(makeRequest(validBody), staticRouteContext())
expect(res.status).toBe(403)
expect(mocks.createCompanyCore).not.toHaveBeenCalled()
})
it('returns 400 for a VAT-registered company without a moms period', async () => {
const res = await createCompany(makeRequest({ ...validBody, moms_period: undefined }), staticRouteContext())
expect(res.status).toBe(400)
const body = await res.json()
expect(JSON.stringify(body)).toContain('moms_period')
expect(mocks.createCompanyCore).not.toHaveBeenCalled()
})
it('returns 400 for a body that is not JSON', async () => {
const res = await createCompany(makeRequest('not json'), staticRouteContext())
expect(res.status).toBe(400)
})
it('creates the company through the service-role RPC for the key user and returns 201', async () => {
const supabase = makeSupabase(TEAM_ID)
mockServiceClient.mockReturnValue(supabase)
const res = await createCompany(makeRequest(validBody), staticRouteContext())
expect(res.status).toBe(201)
const body = await res.json()
expect(body.data).toMatchObject({
id: COMPANY_ID,
name: 'Acme AB',
entity_type: 'aktiebolag',
org_number: '5560000001',
vat_registered: true,
moms_period: 'quarterly',
team_id: TEAM_ID,
})
expect(body.data.fiscal_period.name).toContain('Räkenskapsår')
expect(supabase.rpc).toHaveBeenCalledWith('create_company_for_user', {
p_user_id: USER_ID,
p_name: 'Acme AB',
p_entity_type: 'aktiebolag',
p_team_id: TEAM_ID,
})
})
it('previews without creating for a test-mode key (dry run)', async () => {
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: null,
apiKeyId: 'key-1',
apiKeyName: 'Test key',
scopes: ['companies:write'],
mode: 'test',
})
const res = await createCompany(makeRequest(validBody), staticRouteContext())
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.dry_run).toBe(true)
expect(body.data.preview.name).toBe('Acme AB')
expect(mocks.createCompanyCore).not.toHaveBeenCalled()
})
it('maps a creation failure to INTERNAL_ERROR', async () => {
mocks.createCompanyCore.mockResolvedValue({ error: 'Kunde inte skapa kontoplan. Försök igen.' })
const res = await createCompany(makeRequest(validBody), staticRouteContext())
expect(res.status).toBe(500)
})
})
+165 -3
View File
@@ -10,15 +10,18 @@
*/
import { z } from 'zod'
import { paginated } from '@/lib/api/v1/response'
import { paginated, created } from '@/lib/api/v1/response'
import {
encodeDefaultCursor,
parsePaginationParams,
decodeDefaultCursor,
} from '@/lib/api/v1/pagination'
import { registerEndpoint, listEnvelope } from '@/lib/api/v1/registry'
import { registerEndpoint, listEnvelope, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { createCompanyCore } from '@/lib/company/create-company'
import { CompanySetupSchema, planCompanySetup } from '@/lib/company/onboarding-input'
const Company = z.object({
id: z.string().uuid(),
@@ -70,6 +73,165 @@ registerEndpoint({
response: { success: CompaniesListResponse },
})
const CreatedCompany = z.object({
id: z.string().uuid(),
name: z.string(),
entity_type: z.enum(['enskild_firma', 'aktiebolag']),
org_number: z.string().nullable(),
vat_registered: z.boolean(),
moms_period: z.enum(['monthly', 'quarterly', 'yearly']).nullable(),
accounting_method: z.enum(['accrual', 'cash']),
fiscal_period: z.object({ start_date: z.string(), end_date: z.string(), name: z.string() }),
team_id: z.string().uuid().nullable(),
})
registerEndpoint({
operation: 'companies.create',
method: 'POST',
path: '/api/v1/companies',
summary: 'Create a company and set it up for bookkeeping.',
description:
'Creates a new company owned by the API key user (or attached to one of their teams) and sets it up in one call: ' +
'owner membership, BAS chart of accounts for the company form, compliance settings, the first fiscal period and the ' +
'automatic tax deadlines. A 30-day trial with every paid capability starts immediately. ' +
'Intended for partner platforms provisioning client companies (byrå/vertical SaaS) and for agents onboarding a user.',
useWhen:
'A platform or agent needs to provision a company that does not exist in Accounted yet. The caller becomes its owner; invite the end customer afterwards.',
doNotUseFor:
'Companies that already exist (list them with GET /api/v1/companies), or changing settings on an existing company (PATCH /api/v1/companies/{companyId}/settings).',
pitfalls: [
'A VAT-registered company MUST send moms_period (monthly / quarterly / yearly); the request is refused otherwise, because a missing period silently produces zero VAT deadlines.',
'Bookkeeping duty under BFL starts when the company exists with a fiscal period: do not create companies to try things out. Use a test-mode key (dry run) for that.',
'Enskild firma always runs on the calendar year; fiscal_year_start_month is ignored for it.',
'first_fiscal_year is only for a company in its first year (BFL 3 kap.: up to 18 months). Omit it for an established company.',
'Not idempotent, and Idempotency-Key is not honoured on this company-less route: a retry after a network failure creates a second company. List GET /api/v1/companies before retrying.',
'org_number is required for a VAT-registered company (the invoice momsregistreringsnummer derives from it), and f_skatt must be stated explicitly: F-skatt approval is never assumed.',
],
example: {
request: {
name: 'Acme AB',
entity_type: 'aktiebolag',
org_number: '5566778899',
vat_registered: true,
moms_period: 'quarterly',
accounting_method: 'accrual',
f_skatt: true,
},
response: {
data: {
id: '8fd5b1f4-…',
name: 'Acme AB',
entity_type: 'aktiebolag',
org_number: '5566778899',
vat_registered: true,
moms_period: 'quarterly',
accounting_method: 'accrual',
fiscal_period: { start_date: '2026-01-01', end_date: '2026-12-31', name: 'Räkenskapsår 2026' },
team_id: null,
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'companies:write',
risk: 'medium',
idempotent: false,
reversible: false,
dryRunSupported: true,
request: { body: CompanySetupSchema },
response: { success: dataEnvelope(CreatedCompany), errorCodes: ['VALIDATION_ERROR', 'FORBIDDEN', 'INTERNAL_ERROR'] },
})
export const POST = withApiV1('companies.create', async (request, ctx) => {
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const parsed = CompanySetupSchema.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { issues: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })) },
})
}
const setup = parsed.data
const plan = planCompanySetup(setup)
if (!plan.ok) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'first_fiscal_year', message: plan.error },
})
}
// Team: explicit, else the caller's first (usually personal) team, same as
// the web wizard. create_company_for_user re-checks membership.
let teamId: string | null = setup.team_id ?? null
if (!teamId) {
const { data: membership } = await ctx.supabase
.from('team_members')
.select('team_id')
.eq('user_id', ctx.userId)
.order('created_at', { ascending: true })
.limit(1)
.maybeSingle()
teamId = (membership?.team_id as string | undefined) ?? null
}
const shape = (id: string) => ({
id,
name: setup.name,
entity_type: setup.entity_type,
org_number: (plan.input.settings.org_number as string | null) ?? null,
vat_registered: setup.vat_registered,
moms_period: setup.vat_registered ? setup.moms_period ?? null : null,
accounting_method: setup.accounting_method,
fiscal_period: {
start_date: plan.fiscalPeriod.startDate,
end_date: plan.fiscalPeriod.endDate,
name: plan.fiscalPeriod.name,
},
team_id: teamId,
})
if (ctx.dryRun) {
return dryRunPreview(shape('00000000-0000-4000-8000-000000000000'), {
requestId: ctx.requestId,
log: ctx.log,
})
}
const result = await createCompanyCore(ctx.supabase, plan.input, () =>
ctx.supabase.rpc('create_company_for_user', {
p_user_id: ctx.userId,
p_name: setup.name,
p_entity_type: setup.entity_type,
p_team_id: teamId,
}),
)
if (result.error !== undefined) {
if (result.error === 'org_number_invalid') {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'org_number', message: 'Invalid organisationsnummer.' },
})
}
ctx.log.error('companies.create failed', { reason: result.error })
return v1ErrorResponseFromCode('INTERNAL_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { message: result.error },
})
}
return created(shape(result.companyId), { requestId: ctx.requestId })
})
export const GET = withApiV1('companies.list', async (request, ctx) => {
const url = new URL(request.url)
const { limit, cursor } = parsePaginationParams(url)
@@ -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',
])
/**
+268 -3
View File
@@ -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',
}
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `135`;
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `136`;
exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-keys 1`] = `
[
@@ -79,6 +79,7 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
"PATCH /api/v1/companies/:companyId/supplier-invoices/:id",
"PATCH /api/v1/companies/:companyId/suppliers/:id",
"PATCH /api/v1/companies/:companyId/webhooks/:id",
"POST /api/v1/companies",
"POST /api/v1/companies/:companyId/customers",
"POST /api/v1/companies/:companyId/customers/bulk-create",
"POST /api/v1/companies/:companyId/dimensions/:id/values",
+4 -1
View File
@@ -28,7 +28,7 @@ export const API_KEY_SCOPES = {
'payroll:write': { label: 'Löner: skriv', description: 'Skapa lönekörning, beräkna, generera AGI, logga körjournalresor' },
// v1 REST API: added Phase 1
'companies:read': { label: 'Företag: läs', description: 'Lista och visa företagsprofiler som API-nyckeln har tillgång till' },
'companies:write': { label: 'Företag: skriv', description: 'Uppdatera företagsinställningar via stagade verktyg eller REST-endpointen PATCH /api/v1/companies/{companyId}/settings' },
'companies:write': { label: 'Företag: skriv', description: 'Skapa nya företag och uppdatera företagsinställningar (gnubok_create_company, stagade verktyg, REST POST /api/v1/companies och PATCH /api/v1/companies/{companyId}/settings)' },
'events:read': { label: 'Händelser: läs', description: 'Polla händelseloggen (event_log) som webhook-fallback' },
'webhooks:manage': { label: 'Webhooks: hantera', description: 'Skapa, lista, uppdatera och radera webhook-prenumerationer' },
'operations:read': { label: 'Operationer: läs', description: 'Hämta status för långkörande operationer (importer, bokslut, omvärdering)' },
@@ -179,6 +179,9 @@ export const SCOPE_GROUPS = [
export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
// Companies
gnubok_list_companies: 'companies:read',
gnubok_create_company: 'companies:write',
gnubok_connect_bank: 'companies:read',
gnubok_connect_skatteverket: 'companies:read',
gnubok_get_company_settings: 'companies:read',
gnubok_update_company_settings: 'companies:write',
// Transactions
+2
View File
@@ -38,6 +38,8 @@ export const V1_PUBLIC_ENDPOINTS: ReadonlyArray<string> = [
export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
// Companies
'GET /api/v1/companies': 'companies:read',
// Issue #1814: programmatic company creation (partner provisioning, agents).
'POST /api/v1/companies': 'companies:write',
'GET /api/v1/companies/:companyId': 'companies:read',
// Issue #1348: company-settings write (same field set as the MCP tool
// gnubok_update_company_settings; direct write, no staging).
@@ -0,0 +1,165 @@
import { describe, expect, it } from 'vitest'
import { CompanySetupSchema, planCompanySetup } from '../onboarding-input'
const base = {
name: 'Testbolaget AB',
entity_type: 'aktiebolag' as const,
org_number: '5560000001',
vat_registered: true,
moms_period: 'quarterly' as const,
accounting_method: 'accrual' as const,
f_skatt: true,
}
describe('CompanySetupSchema', () => {
it('accepts a complete aktiebolag setup', () => {
expect(CompanySetupSchema.safeParse(base).success).toBe(true)
})
it('refuses a VAT-registered company without a moms_period (silent zero-deadline trap)', () => {
const result = CompanySetupSchema.safeParse({ ...base, moms_period: undefined })
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.issues.some((i) => i.path.join('.') === 'moms_period')).toBe(true)
}
})
it('refuses a moms_period on a company that is not VAT-registered', () => {
const result = CompanySetupSchema.safeParse({ ...base, vat_registered: false })
expect(result.success).toBe(false)
})
it('accepts a non-VAT company with no moms_period', () => {
const result = CompanySetupSchema.safeParse({ ...base, vat_registered: false, moms_period: undefined })
expect(result.success).toBe(true)
})
it('refuses a VAT-registered company without an org number (invoice momsregistreringsnummer)', () => {
const result = CompanySetupSchema.safeParse({ ...base, org_number: undefined })
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.issues.some((i) => i.path.join('.') === 'org_number')).toBe(true)
}
})
it('accepts a non-VAT company without an org number', () => {
const result = CompanySetupSchema.safeParse({
...base,
org_number: undefined,
vat_registered: false,
moms_period: undefined,
})
expect(result.success).toBe(true)
})
it('refuses an omitted f_skatt: F-skatt approval is never assumed', () => {
const result = CompanySetupSchema.safeParse({ ...base, f_skatt: undefined })
expect(result.success).toBe(false)
})
it('refuses an enskild firma first fiscal year that does not end on 31 December', () => {
const result = CompanySetupSchema.safeParse({
...base,
entity_type: 'enskild_firma',
first_fiscal_year: { start: '2026-03-15', end: '2027-06-30' },
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.issues.some((i) => i.path.join('.') === 'first_fiscal_year.end')).toBe(true)
}
})
it('refuses a malformed organisationsnummer', () => {
const result = CompanySetupSchema.safeParse({ ...base, org_number: '1234' })
expect(result.success).toBe(false)
})
})
describe('planCompanySetup', () => {
it('produces the wizard-shaped settings and a calendar-year period by default', () => {
const plan = planCompanySetup(CompanySetupSchema.parse(base))
expect(plan.ok).toBe(true)
if (!plan.ok) return
const year = new Date().getFullYear()
expect(plan.fiscalPeriod.startDate).toBe(`${year}-01-01`)
expect(plan.fiscalPeriod.endDate).toBe(`${year}-12-31`)
expect(plan.input.settings).toMatchObject({
entity_type: 'aktiebolag',
company_name: 'Testbolaget AB',
org_number: '5560000001',
vat_registered: true,
vat_number: 'SE556000000101',
moms_period: 'quarterly',
accounting_method: 'accrual',
f_skatt: true,
fiscal_year_start_month: 1,
is_first_fiscal_year: false,
})
})
it('forces enskild firma onto the calendar year regardless of the requested start month', () => {
const plan = planCompanySetup(
CompanySetupSchema.parse({ ...base, entity_type: 'enskild_firma', fiscal_year_start_month: 7 })
)
expect(plan.ok).toBe(true)
if (!plan.ok) return
expect(plan.input.settings.fiscal_year_start_month).toBe(1)
expect(plan.fiscalPeriod.startDate.endsWith('-01-01')).toBe(true)
})
it('uses the first fiscal year dates and derives the start month from its end', () => {
const plan = planCompanySetup(
CompanySetupSchema.parse({
...base,
first_fiscal_year: { start: '2026-03-15', end: '2027-06-30' },
})
)
expect(plan.ok).toBe(true)
if (!plan.ok) return
expect(plan.fiscalPeriod).toMatchObject({ startDate: '2026-03-15', endDate: '2027-06-30' })
expect(plan.fiscalPeriod.name).toContain('Första räkenskapsåret')
expect(plan.input.settings.fiscal_year_start_month).toBe(7)
expect(plan.input.settings.is_first_fiscal_year).toBe(true)
})
it('keeps an enskild firma on the calendar year through a first fiscal year ending 31 December', () => {
const plan = planCompanySetup(
CompanySetupSchema.parse({
...base,
entity_type: 'enskild_firma',
first_fiscal_year: { start: '2026-03-15', end: '2026-12-31' },
})
)
expect(plan.ok).toBe(true)
if (!plan.ok) return
expect(plan.fiscalPeriod).toMatchObject({ startDate: '2026-03-15', endDate: '2026-12-31' })
expect(plan.input.settings.fiscal_year_start_month).toBe(1)
})
it('carries f_skatt=false through instead of defaulting to approved', () => {
const plan = planCompanySetup(CompanySetupSchema.parse({ ...base, f_skatt: false }))
expect(plan.ok).toBe(true)
if (!plan.ok) return
expect(plan.input.settings.f_skatt).toBe(false)
})
it('rejects a first fiscal year longer than 18 months', () => {
const plan = planCompanySetup(
CompanySetupSchema.parse({
...base,
first_fiscal_year: { start: '2026-01-01', end: '2027-12-31' },
})
)
expect(plan.ok).toBe(false)
})
it('leaves vat_number null and moms_period null for a non-VAT company', () => {
const plan = planCompanySetup(
CompanySetupSchema.parse({ ...base, vat_registered: false, moms_period: undefined })
)
expect(plan.ok).toBe(true)
if (!plan.ok) return
expect(plan.input.settings.vat_number).toBeNull()
expect(plan.input.settings.moms_period).toBeNull()
})
})
+21 -171
View File
@@ -3,13 +3,7 @@
import { createClient } from '@/lib/supabase/server'
import { setActiveCompany, CompanyContextError } from '@/lib/company/context'
import { revalidatePath } from 'next/cache'
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
import { normalizeVatNumber, isValidSwedishVatNumber, deriveSwedishVatNumber } from '@/lib/vat/vat-number'
import {
regenerateTaxDeadlinesForUser,
toDeadlineSettings,
} from '@/lib/tax/deadline-generator'
import type { CompanySettingsForDeadlines } from '@/lib/tax/deadline-config'
import { createCompanyCore } from '@/lib/company/create-company'
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
import { getErrorMessage } from '@/lib/errors/get-error-message'
@@ -101,174 +95,30 @@ async function createCompanyFromOnboardingImpl(params: {
const companyName = (params.settings.company_name as string | undefined) || 'Mitt företag'
// Org-number format validation. We intentionally do NOT enforce
// uniqueness: the same org number may legitimately appear on multiple
// companies (a separate test copy of your real company, or a consultant
// and the owner each tracking the same entity). Tenant isolation
// (RLS + company_id) is the real boundary, not org-number uniqueness.
//
// normalizeOrgNumber returns null for malformed input: we refuse rather
// than storing a value that would break SIE/SRU exports later.
const rawOrgNumber = params.settings.org_number as string | undefined
const cleanedOrgNumber = normalizeOrgNumber(rawOrgNumber)
if (rawOrgNumber && rawOrgNumber.trim() && !cleanedOrgNumber) {
return { error: 'org_number_invalid' }
}
// 1. Create company + owner membership atomically via RPC
const { data: newCompanyId, error: companyError } = await supabase.rpc('create_company_with_owner', {
p_name: companyName,
p_entity_type: entityType,
p_team_id: params.teamId,
})
if (companyError || !newCompanyId) {
console.error('[createCompanyFromOnboarding] company creation failed', companyError)
return { error: 'Kunde inte skapa företag. Försök igen.' }
}
// Helper: roll back the company if a subsequent step fails. Deletes in FK
// order. Each delete is error-checked so a failed cleanup leaves a trace
// instead of silently stranding partial company data behind a generic
// "try again" message.
const rollback = async (reason: string, err: unknown) => {
console.error(`[createCompanyFromOnboarding] rolling back ${newCompanyId}: ${reason}`, err)
const deletions: Array<[table: string, run: () => PromiseLike<{ error: unknown }>]> = [
['company_settings', () => supabase.from('company_settings').delete().eq('company_id', newCompanyId)],
['fiscal_periods', () => supabase.from('fiscal_periods').delete().eq('company_id', newCompanyId)],
['chart_of_accounts', () => supabase.from('chart_of_accounts').delete().eq('company_id', newCompanyId)],
['company_members', () => supabase.from('company_members').delete().eq('company_id', newCompanyId)],
['companies', () => supabase.from('companies').delete().eq('id', newCompanyId)],
]
for (const [table, run] of deletions) {
const { error: deleteError } = await run()
if (deleteError) {
console.error(
`[createCompanyFromOnboarding] rollback delete failed for ${table} (company ${newCompanyId})`,
deleteError,
)
}
}
}
// Mirror the normalized org_number onto the companies row so future
// duplicate checks and cross-references are reliable. MUST be error-checked
// and rolled back on failure: otherwise the freshly-created company would
// exist without an org_number and the duplicate guard would never match it
// for any future user (the very guard this code is enforcing).
if (cleanedOrgNumber) {
const { error: orgUpdateError } = await supabase
.from('companies')
.update({ org_number: cleanedOrgNumber })
.eq('id', newCompanyId)
if (orgUpdateError) {
await rollback('org_number update failed', orgUpdateError)
return { error: 'Kunde inte spara organisationsnummer. Försök igen.' }
}
}
// Persist whatever lookup data the wizard already gathered. Do NOT call
// /profile here: that handler fans out to 13 Lens calls and the 5 s
// timeout in tic-fetch.ts ate ~530 wasted calls in May before yielding
// zero snapshots (every signup's /profile timed out, but the in-flight
// upstream fetches still counted against quota). The agent build path
// (app/(onboarding)/onboarding/agent/page.tsx) calls ensureTicSnapshot
// with upgradeV1: true lazily, which is the right place: only companies
// that actually reach agent onboarding spend the budget.
if (params.ticLookup) {
const { error: ticErr } = await supabase
.from('companies')
.update({
tic_snapshot: params.ticLookup,
tic_snapshot_fetched_at: new Date().toISOString(),
})
.eq('id', newCompanyId)
if (ticErr) {
console.warn('[createCompanyFromOnboarding] tic snapshot persist failed', ticErr)
}
}
// 2. Seed chart of accounts
const { error: coaError } = await supabase.rpc('seed_chart_of_accounts', {
p_company_id: newCompanyId,
p_entity_type: entityType,
})
if (coaError) {
await rollback('COA seeding failed', coaError)
return { error: 'Kunde inte skapa kontoplan. Försök igen.' }
}
// 3. Save settings (strip UI-only and managed fields)
const {
id: _id,
user_id: _uid,
company_id: _cid,
created_at: _ca,
updated_at: _ua,
is_first_fiscal_year: _ify,
first_year_start: _fys,
first_year_end: _fye,
...settingsToSave
} = params.settings
// Defence in depth: this upsert bypasses UpdateSettingsSchema, so never persist
// a VAT number blind. Normalise to the canonical SE+12 form; if it isn't
// structurally valid (e.g. the legacy SE+14 personnummer derivation), re-derive
// it from the org number, falling back to null rather than storing a malformed
// momsregistreringsnummer.
if (typeof settingsToSave.vat_number === 'string' && settingsToSave.vat_number) {
const normalized = normalizeVatNumber(settingsToSave.vat_number)
settingsToSave.vat_number = isValidSwedishVatNumber(normalized)
? normalized
: deriveSwedishVatNumber(settingsToSave.org_number as string | null | undefined)
}
const { error: settingsError } = await supabase
.from('company_settings')
.upsert(
{
...settingsToSave,
company_id: newCompanyId,
onboarding_complete: true,
onboarding_step: 4,
},
{ onConflict: 'company_id' },
)
if (settingsError) {
await rollback('settings upsert failed', settingsError)
return { error: 'Kunde inte spara inställningar. Försök igen.' }
}
// 4. Create fiscal period
const { error: periodError } = await supabase.from('fiscal_periods').upsert(
// Steps 1-5 (company + owner via RPC, org number, TIC snapshot, chart,
// settings, fiscal period, tax deadlines, with rollback) are shared with
// the MCP and v1 creation paths: lib/company/create-company.ts.
const created = await createCompanyCore(
supabase,
{
company_id: newCompanyId,
name: params.fiscalPeriod.name,
period_start: params.fiscalPeriod.startDate,
period_end: params.fiscalPeriod.endDate,
entityType,
companyName,
orgNumber: params.settings.org_number as string | undefined,
settings: params.settings,
fiscalPeriod: params.fiscalPeriod,
ticLookup: params.ticLookup,
},
{ onConflict: 'company_id,period_start,period_end' },
() =>
supabase.rpc('create_company_with_owner', {
p_name: companyName,
p_entity_type: entityType,
p_team_id: params.teamId,
}),
)
if (periodError) {
await rollback('fiscal period upsert failed', periodError)
return { error: 'Kunde inte skapa räkenskapsår. Försök igen.' }
}
// 5. Create the automatic tax deadlines while the onboarding data is still
// available. Treat this as part of company creation so a new company never
// starts in the broken state where valid settings exist without deadlines.
try {
await regenerateTaxDeadlinesForUser(
supabase,
newCompanyId,
toDeadlineSettings(settingsToSave as Partial<CompanySettingsForDeadlines>),
)
} catch (deadlineError) {
await rollback('tax deadline generation failed', deadlineError)
return { error: 'Kunde inte skapa skattedeadlines. Försök igen.' }
if (created.error !== undefined) {
return { error: created.error }
}
const newCompanyId = created.companyId
// 6. Set as active company
try {
+224
View File
@@ -0,0 +1,224 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
import { normalizeVatNumber, isValidSwedishVatNumber, deriveSwedishVatNumber } from '@/lib/vat/vat-number'
import { regenerateTaxDeadlinesForUser, toDeadlineSettings } from '@/lib/tax/deadline-generator'
import type { CompanySettingsForDeadlines } from '@/lib/tax/deadline-config'
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
import type { EntityType } from '@/types'
/**
* The one company-creation sequence, shared by the web wizard (Server
* Action, cookie session, create_company_with_owner), the MCP tool
* gnubok_create_company and POST /api/v1/companies (service client,
* create_company_for_user). Issue #1814 PR 3.
*
* All steps after the company row exists roll back on failure so a company
* never survives half-configured: bookkeeping duty under BFL starts the
* moment the tenant is real, and a company without a chart, a fiscal period
* or its tax deadlines is worse than no company.
*
* The caller supplies `createCompanyRow`, the RPC call that inserts the
* company + owner membership: which RPC is right depends on whether the
* caller has an auth.uid() (cookie session) or an explicit owner (service
* role). Everything else is identical.
*/
export interface CreateCompanyInput {
entityType: EntityType
companyName: string
/** Raw org number as typed; normalised here, refused when malformed. */
orgNumber?: string | null
/**
* company_settings partial to persist. UI-only and managed fields are
* stripped here (id, user_id, company_id, timestamps, first-fiscal-year
* helpers), so callers may pass the wizard state as-is.
*/
settings: Record<string, unknown>
fiscalPeriod: { startDate: string; endDate: string; name: string }
ticLookup?: CompanyLookupResult | null
}
export type CreateCompanyResult = { companyId: string; error?: undefined } | { companyId?: undefined; error: string }
export const COMPANY_CREATION_ERRORS = {
org_number_invalid: 'org_number_invalid',
create_failed: 'Kunde inte skapa företag. Försök igen.',
org_number_save_failed: 'Kunde inte spara organisationsnummer. Försök igen.',
chart_failed: 'Kunde inte skapa kontoplan. Försök igen.',
settings_failed: 'Kunde inte spara inställningar. Försök igen.',
period_failed: 'Kunde inte skapa räkenskapsår. Försök igen.',
deadlines_failed: 'Kunde inte skapa skattedeadlines. Försök igen.',
} as const
export async function createCompanyCore(
supabase: SupabaseClient,
input: CreateCompanyInput,
createCompanyRow: () => PromiseLike<{ data: unknown; error: unknown }>,
): Promise<CreateCompanyResult> {
// Org-number format validation. We intentionally do NOT enforce
// uniqueness: the same org number may legitimately appear on multiple
// companies (a separate test copy of your real company, or a consultant
// and the owner each tracking the same entity). Tenant isolation
// (RLS + company_id) is the real boundary, not org-number uniqueness.
//
// normalizeOrgNumber returns null for malformed input: we refuse rather
// than storing a value that would break SIE/SRU exports later.
const rawOrgNumber = input.orgNumber ?? undefined
const cleanedOrgNumber = normalizeOrgNumber(rawOrgNumber)
if (rawOrgNumber && rawOrgNumber.trim() && !cleanedOrgNumber) {
return { error: COMPANY_CREATION_ERRORS.org_number_invalid }
}
// 1. Create company + owner membership atomically via RPC
const { data: newCompanyIdRaw, error: companyError } = await createCompanyRow()
const newCompanyId = typeof newCompanyIdRaw === 'string' ? newCompanyIdRaw : null
if (companyError || !newCompanyId) {
console.error('[createCompany] company creation failed', companyError)
return { error: COMPANY_CREATION_ERRORS.create_failed }
}
// Helper: roll back the company if a subsequent step fails. Deletes in FK
// order. Each delete is error-checked so a failed cleanup leaves a trace
// instead of silently stranding partial company data behind a generic
// "try again" message.
const rollback = async (reason: string, err: unknown) => {
console.error(`[createCompany] rolling back ${newCompanyId}: ${reason}`, err)
const deletions: Array<[table: string, run: () => PromiseLike<{ error: unknown }>]> = [
['company_settings', () => supabase.from('company_settings').delete().eq('company_id', newCompanyId)],
['fiscal_periods', () => supabase.from('fiscal_periods').delete().eq('company_id', newCompanyId)],
['chart_of_accounts', () => supabase.from('chart_of_accounts').delete().eq('company_id', newCompanyId)],
['company_members', () => supabase.from('company_members').delete().eq('company_id', newCompanyId)],
['companies', () => supabase.from('companies').delete().eq('id', newCompanyId)],
]
for (const [table, run] of deletions) {
const { error: deleteError } = await run()
if (deleteError) {
console.error(
`[createCompany] rollback delete failed for ${table} (company ${newCompanyId})`,
deleteError,
)
}
}
}
// Mirror the normalized org_number onto the companies row so future
// duplicate checks and cross-references are reliable. MUST be error-checked
// and rolled back on failure: otherwise the freshly-created company would
// exist without an org_number and the duplicate guard would never match it
// for any future user (the very guard this code is enforcing).
if (cleanedOrgNumber) {
const { error: orgUpdateError } = await supabase
.from('companies')
.update({ org_number: cleanedOrgNumber })
.eq('id', newCompanyId)
if (orgUpdateError) {
await rollback('org_number update failed', orgUpdateError)
return { error: COMPANY_CREATION_ERRORS.org_number_save_failed }
}
}
// Persist whatever lookup data the caller already gathered. Do NOT call
// /profile here: that handler fans out to 13 Lens calls and the 5 s
// timeout in tic-fetch.ts ate ~530 wasted calls in May before yielding
// zero snapshots (every signup's /profile timed out, but the in-flight
// upstream fetches still counted against quota). The agent build path
// (app/(onboarding)/onboarding/agent/page.tsx) calls ensureTicSnapshot
// with upgradeV1: true lazily, which is the right place: only companies
// that actually reach agent onboarding spend the budget.
if (input.ticLookup) {
const { error: ticErr } = await supabase
.from('companies')
.update({
tic_snapshot: input.ticLookup,
tic_snapshot_fetched_at: new Date().toISOString(),
})
.eq('id', newCompanyId)
if (ticErr) {
console.warn('[createCompany] tic snapshot persist failed', ticErr)
}
}
// 2. Seed chart of accounts
const { error: coaError } = await supabase.rpc('seed_chart_of_accounts', {
p_company_id: newCompanyId,
p_entity_type: input.entityType,
})
if (coaError) {
await rollback('COA seeding failed', coaError)
return { error: COMPANY_CREATION_ERRORS.chart_failed }
}
// 3. Save settings (strip UI-only and managed fields)
const {
id: _id,
user_id: _uid,
company_id: _cid,
created_at: _ca,
updated_at: _ua,
is_first_fiscal_year: _ify,
first_year_start: _fys,
first_year_end: _fye,
...settingsToSave
} = input.settings
// Defence in depth: this upsert bypasses UpdateSettingsSchema, so never persist
// a VAT number blind. Normalise to the canonical SE+12 form; if it isn't
// structurally valid (e.g. the legacy SE+14 personnummer derivation), re-derive
// it from the org number, falling back to null rather than storing a malformed
// momsregistreringsnummer.
if (typeof settingsToSave.vat_number === 'string' && settingsToSave.vat_number) {
const normalized = normalizeVatNumber(settingsToSave.vat_number)
settingsToSave.vat_number = isValidSwedishVatNumber(normalized)
? normalized
: deriveSwedishVatNumber(settingsToSave.org_number as string | null | undefined)
}
const { error: settingsError } = await supabase
.from('company_settings')
.upsert(
{
...settingsToSave,
company_id: newCompanyId,
onboarding_complete: true,
onboarding_step: 4,
},
{ onConflict: 'company_id' },
)
if (settingsError) {
await rollback('settings upsert failed', settingsError)
return { error: COMPANY_CREATION_ERRORS.settings_failed }
}
// 4. Create fiscal period
const { error: periodError } = await supabase.from('fiscal_periods').upsert(
{
company_id: newCompanyId,
name: input.fiscalPeriod.name,
period_start: input.fiscalPeriod.startDate,
period_end: input.fiscalPeriod.endDate,
},
{ onConflict: 'company_id,period_start,period_end' },
)
if (periodError) {
await rollback('fiscal period upsert failed', periodError)
return { error: COMPANY_CREATION_ERRORS.period_failed }
}
// 5. Create the automatic tax deadlines while the onboarding data is still
// available. Treat this as part of company creation so a new company never
// starts in the broken state where valid settings exist without deadlines.
try {
await regenerateTaxDeadlinesForUser(
supabase,
newCompanyId,
toDeadlineSettings(settingsToSave as Partial<CompanySettingsForDeadlines>),
)
} catch (deadlineError) {
await rollback('tax deadline generation failed', deadlineError)
return { error: COMPANY_CREATION_ERRORS.deadlines_failed }
}
return { companyId: newCompanyId }
}
+162
View File
@@ -0,0 +1,162 @@
import { z } from 'zod'
import { saneIsoDateSchema } from '@/lib/invariants/zod'
import { computeFiscalPeriod } from '@/lib/company/compute-fiscal-period'
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
import { deriveSwedishVatNumber } from '@/lib/vat/vat-number'
import type { CreateCompanyInput } from '@/lib/company/create-company'
/**
* Typed company-setup input for the agent and API creation paths (the MCP
* tool gnubok_create_company and POST /api/v1/companies, issue #1814 PR 3),
* turned into the same `settings` + `fiscalPeriod` shape the web wizard
* hands to createCompanyCore. One schema, one builder, so the two
* programmatic paths cannot drift from each other or from the wizard.
*
* Compliance rules that must never be skipped: a VAT-registered company
* needs a moms_period and an org_number (the invoice momsregistreringsnummer
* derives from it), F-skatt is stated explicitly rather than assumed, and an
* enskild firma stays on the calendar year even in its first year. Chief
* among them, the moms_period: a VAT-registered company
* needs a moms_period. Without it the deadline engine silently generates
* ZERO VAT deadlines (lib/tax/deadline-config.ts conditions all require a
* concrete period), which reads as "no VAT duty" to everyone downstream.
* The schema refuses the combination instead of warning.
*/
export const CompanySetupSchema = z
.object({
name: z.string().trim().min(1).max(200),
entity_type: z.enum(['enskild_firma', 'aktiebolag']),
org_number: z.string().trim().min(1).max(20).optional(),
vat_registered: z.boolean(),
moms_period: z.enum(['monthly', 'quarterly', 'yearly']).nullable().optional(),
accounting_method: z.enum(['accrual', 'cash']),
/** Godkänd för F-skatt. Explicit on purpose: never assumed (SE-R-005 risk). */
f_skatt: z.boolean(),
/** 1-12. Ignored for enskild firma (always calendar year). */
fiscal_year_start_month: z.number().int().min(1).max(12).optional(),
/**
* First fiscal year of a newly formed company (BFL 3 kap.): may be
* shorter or longer than 12 months. Both dates YYYY-MM-DD.
*/
first_fiscal_year: z
.object({
start: saneIsoDateSchema,
end: saneIsoDateSchema,
})
.optional(),
address_line1: z.string().trim().max(200).optional(),
postal_code: z.string().trim().max(20).optional(),
city: z.string().trim().max(100).optional(),
/** Team (byrå) to attach the company to. Defaults to the caller's own team. */
team_id: z.string().uuid().optional(),
})
.superRefine((value, ctx) => {
if (value.vat_registered && !value.moms_period) {
ctx.addIssue({
code: 'custom',
path: ['moms_period'],
message:
'moms_period is required when vat_registered is true: without it no VAT deadlines are generated (silent misconfiguration).',
})
}
if (!value.vat_registered && value.moms_period) {
ctx.addIssue({
code: 'custom',
path: ['moms_period'],
message: 'moms_period must be omitted when the company is not VAT-registered.',
})
}
if (value.vat_registered && !value.org_number) {
ctx.addIssue({
code: 'custom',
path: ['org_number'],
message:
'org_number is required for a VAT-registered company: the momsregistreringsnummer on every invoice is derived from it (ML 17 kap 24 §).',
})
}
if (
value.entity_type === 'enskild_firma' &&
value.first_fiscal_year &&
!value.first_fiscal_year.end.endsWith('-12-31')
) {
ctx.addIssue({
code: 'custom',
path: ['first_fiscal_year', 'end'],
message:
'An enskild firma always closes its fiscal year on 31 December (BFL 3 kap. 1 §): the first year may be shorter or up to 18 months, but must end on 12-31.',
})
}
if (value.org_number && normalizeOrgNumber(value.org_number) === null) {
ctx.addIssue({
code: 'custom',
path: ['org_number'],
message: 'org_number is not a valid Swedish organisationsnummer.',
})
}
})
export type CompanySetup = z.infer<typeof CompanySetupSchema>
export type CompanySetupPlan =
| {
ok: true
input: Omit<CreateCompanyInput, 'ticLookup'>
/** What the fiscal period resolved to, for previews. */
fiscalPeriod: { startDate: string; endDate: string; name: string }
}
| { ok: false; error: string }
/**
* Resolve validated setup input into the creation payload. The only failure
* left at this point is an invalid fiscal period (validatePeriodDuration).
*/
export function planCompanySetup(setup: CompanySetup): CompanySetupPlan {
const isEf = setup.entity_type === 'enskild_firma'
const firstYear = setup.first_fiscal_year
const startMonth = isEf ? 1 : (setup.fiscal_year_start_month ?? 1)
const settings: Record<string, unknown> = {
entity_type: setup.entity_type,
company_name: setup.name,
org_number: setup.org_number ? normalizeOrgNumber(setup.org_number) : null,
vat_registered: setup.vat_registered,
vat_number: setup.vat_registered ? deriveSwedishVatNumber(setup.org_number ?? null) : null,
moms_period: setup.vat_registered ? setup.moms_period ?? null : null,
accounting_method: setup.accounting_method,
f_skatt: setup.f_skatt,
// Enskild firma is calendar-year by law, with or without a first year.
fiscal_year_start_month: isEf ? 1 : firstYear ? nextMonthAfter(firstYear.end) : startMonth,
...(setup.address_line1 ? { address_line1: setup.address_line1 } : {}),
...(setup.postal_code ? { postal_code: setup.postal_code } : {}),
...(setup.city ? { city: setup.city } : {}),
// Wizard helpers consumed by computeFiscalPeriod and stripped by
// createCompanyCore before the settings upsert.
is_first_fiscal_year: Boolean(firstYear),
first_year_start: firstYear?.start,
first_year_end: firstYear?.end,
}
const period = computeFiscalPeriod(settings)
if (period.error) {
return { ok: false, error: period.error }
}
return {
ok: true,
input: {
entityType: setup.entity_type,
companyName: setup.name,
orgNumber: setup.org_number,
settings,
fiscalPeriod: { startDate: period.startStr, endDate: period.endStr, name: period.periodName },
},
fiscalPeriod: { startDate: period.startStr, endDate: period.endStr, name: period.periodName },
}
}
/** The fiscal_year_start_month implied by a first fiscal year ending in `end`. */
function nextMonthAfter(end: string): number {
const endMonth = Number(end.split('-')[1])
if (!Number.isInteger(endMonth) || endMonth < 1 || endMonth > 12) return 1
return endMonth === 12 ? 1 : endMonth + 1
}
@@ -22,6 +22,9 @@ const DISPATCH_ONLY_MCP_TOOLS = new Set<string>([
'gnubok_upload_document',
'gnubok_create_document_upload',
'gnubok_complete_document_upload',
// Onboarding connect-link tools: read status + hand out a browser link; no commit counterpart.
'gnubok_connect_bank',
'gnubok_connect_skatteverket',
])
describe('MCP_TOOL_CAPABILITY_MAP', () => {
@@ -30,6 +33,8 @@ describe('MCP_TOOL_CAPABILITY_MAP', () => {
gnubok_send_invoice: CAPABILITY.email_send,
gnubok_vat_declaration_submit: CAPABILITY.skatteverket,
gnubok_agi_submit: CAPABILITY.skatteverket,
gnubok_connect_bank: CAPABILITY.bank_sync,
gnubok_connect_skatteverket: CAPABILITY.skatteverket,
// Dispatch-only AI tools: inline Bedrock OCR, no staged operation. The
// signed-URL pair is gated at create AND complete so a free-tier key can
// neither reserve nor finalize a paid extraction.
+3
View File
@@ -89,6 +89,9 @@ export const MCP_TOOL_CAPABILITY_MAP: Readonly<Partial<Record<string, Capability
gnubok_send_invoice: CAPABILITY.email_send,
gnubok_vat_declaration_submit: CAPABILITY.skatteverket,
gnubok_agi_submit: CAPABILITY.skatteverket,
// Onboarding connect-link tools (issue #1814): gated like the links' targets.
gnubok_connect_bank: CAPABILITY.bank_sync,
gnubok_connect_skatteverket: CAPABILITY.skatteverket,
// AI document OCR (Bedrock): the inbox's paid extraction, reachable via MCP.
gnubok_create_document_upload: CAPABILITY.ai,
gnubok_complete_document_upload: CAPABILITY.ai,
+4 -3
View File
@@ -8,7 +8,7 @@ description: >-
transactions and reconciliation, payroll (lön), VAT/moms and financial
reports, SIE import/export, documents, webhooks. Covers auth with
gnubok_sk_ API keys, conventions (dry-run, idempotency, cursor
pagination, scopes), and all 135 endpoints.
pagination, scopes), and all 136 endpoints.
---
<!-- GENERATED FILE, do not edit. Source: lib/api/v1 registry + scripts/api-skill/overlays. Regenerate with `npm run apiskill:generate`. -->
@@ -140,15 +140,16 @@ call can undo it, e.g. invoice credit).
## Endpoint index
API version `2026-05-12`, 135 operations. Paths are shown without
API version `2026-05-12`, 136 operations. Paths are shown without
their `/api/v1` prefix (full base URL: `https://app.gnubok.se/api/v1`).
### Core (4)
### Core (5)
Full detail: [references/core.md](references/core.md)
```text
GET /companies : List companies the API key can access [scope:companies:read risk:low idempotent]
POST /companies : Create a company and set it up for bookkeeping [scope:companies:write risk:medium dry-run]
PATCH /companies/{companyId}/settings : Partially update company settings [scope:companies:write risk:medium idempotent dry-run reversible]
GET /health : Health check [risk:low idempotent]
GET /operations/{id} : Poll a long-running operation by id [scope:operations:read risk:low idempotent]
+63
View File
@@ -37,6 +37,69 @@ Response `200`:
---
### `POST /api/v1/companies`
**Create a company and set it up for bookkeeping.**
`scope:companies:write · risk:medium · dry-run`
Creates a new company owned by the API key user (or attached to one of their teams) and sets it up in one call: owner membership, BAS chart of accounts for the company form, compliance settings, the first fiscal period and the automatic tax deadlines. A 30-day trial with every paid capability starts immediately. Intended for partner platforms provisioning client companies (byrå/vertical SaaS) and for agents onboarding a user.
**Use when:** A platform or agent needs to provision a company that does not exist in Accounted yet. The caller becomes its owner; invite the end customer afterwards.
**Do not use for:** Companies that already exist (list them with GET /api/v1/companies), or changing settings on an existing company (PATCH /api/v1/companies/{companyId}/settings).
**Pitfalls:**
- A VAT-registered company MUST send moms_period (monthly / quarterly / yearly); the request is refused otherwise, because a missing period silently produces zero VAT deadlines.
- Bookkeeping duty under BFL starts when the company exists with a fiscal period: do not create companies to try things out. Use a test-mode key (dry run) for that.
- Enskild firma always runs on the calendar year; fiscal_year_start_month is ignored for it.
- first_fiscal_year is only for a company in its first year (BFL 3 kap.: up to 18 months). Omit it for an established company.
- Not idempotent, and Idempotency-Key is not honoured on this company-less route: a retry after a network failure creates a second company. List GET /api/v1/companies before retrying.
- org_number is required for a VAT-registered company (the invoice momsregistreringsnummer derives from it), and f_skatt must be stated explicitly: F-skatt approval is never assumed.
Request body:
```ts
{
name: string,
entity_type: "enskild_firma" | "aktiebolag",
org_number?: string,
vat_registered: boolean,
moms_period?: "monthly" | "quarterly" | "yearly",
accounting_method: "accrual" | "cash",
f_skatt: boolean,
fiscal_year_start_month?: number,
first_fiscal_year?: { start: string, end: string },
address_line1?: string,
postal_code?: string,
city?: string,
team_id?: string
}
```
Response `200`:
```ts
{
data: {
id: string,
name: string,
entity_type: "enskild_firma" | "aktiebolag",
org_number: string,
vat_registered: boolean,
moms_period: "monthly" | "quarterly" | "yearly",
accounting_method: "accrual" | "cash",
fiscal_period: { start_date: string, end_date: string, name: string },
team_id: string
},
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
```
---
### `PATCH /api/v1/companies/{companyId}/settings`
**Partially update company settings.**
@@ -0,0 +1,97 @@
-- Migration: create_company_for_user (service-role company creation)
--
-- Agent-first onboarding (issue #1814 PR 3): the MCP tool gnubok_create_company
-- and POST /api/v1/companies create companies on behalf of the API key's
-- user. Both run with a service-role client, where auth.uid() is NULL, so
-- create_company_with_owner (which derives the owner from auth.uid()) cannot
-- be used. This variant takes the owner explicitly and is callable by
-- service_role ONLY: an authenticated or anonymous caller must never be able
-- to create a company for someone else.
--
-- Body mirrors create_company_with_owner (20260519180000) step for step:
-- entity_type whitelist, team-membership authorization for p_team_id, the
-- companies + company_members inserts, the 1930 SEK cash account seed, the
-- active-company preference, and team sync. The trial capability grant is
-- minted by the AFTER INSERT trigger on companies, same as every other path.
CREATE OR REPLACE FUNCTION public.create_company_for_user(
p_user_id uuid,
p_name text,
p_entity_type text,
p_team_id uuid DEFAULT NULL
)
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_company_id uuid;
BEGIN
IF p_user_id IS NULL THEN
RAISE EXCEPTION 'p_user_id is required';
END IF;
IF NOT EXISTS (SELECT 1 FROM auth.users WHERE id = p_user_id) THEN
RAISE EXCEPTION 'Unknown user %', p_user_id
USING ERRCODE = '23503'; -- foreign_key_violation
END IF;
IF p_entity_type NOT IN ('enskild_firma', 'aktiebolag') THEN
RAISE EXCEPTION 'Invalid entity_type: %', p_entity_type;
END IF;
IF p_name IS NULL OR length(btrim(p_name)) = 0 THEN
RAISE EXCEPTION 'p_name is required';
END IF;
-- Same authorization as create_company_with_owner, against the explicit
-- owner: SECURITY DEFINER bypasses RLS, so team membership is checked here.
IF p_team_id IS NOT NULL THEN
IF NOT EXISTS (
SELECT 1
FROM public.team_members
WHERE team_id = p_team_id
AND user_id = p_user_id
) THEN
RAISE EXCEPTION 'Not a member of team %', p_team_id
USING ERRCODE = '42501'; -- insufficient_privilege
END IF;
END IF;
INSERT INTO public.companies (name, entity_type, created_by, team_id)
VALUES (btrim(p_name), p_entity_type, p_user_id, p_team_id)
RETURNING id INTO v_company_id;
INSERT INTO public.company_members (company_id, user_id, role)
VALUES (v_company_id, p_user_id, 'owner');
INSERT INTO public.cash_accounts (
company_id, ledger_account, currency, name, enabled, is_primary, source
)
VALUES (
v_company_id, '1930', 'SEK', 'Företagskonto (SEK)', true, true, 'manual'
)
ON CONFLICT (company_id, ledger_account) DO NOTHING;
INSERT INTO public.user_preferences (user_id, active_company_id)
VALUES (p_user_id, v_company_id)
ON CONFLICT (user_id)
DO UPDATE SET active_company_id = EXCLUDED.active_company_id;
IF p_team_id IS NOT NULL THEN
PERFORM public.sync_team_to_company(v_company_id, p_team_id);
END IF;
RETURN v_company_id;
END;
$$;
-- Service role only. PostgREST exposes functions to every role by default
-- (PUBLIC grant), so revoke first, then grant the one role that may call it.
REVOKE ALL ON FUNCTION public.create_company_for_user(uuid, text, text, uuid) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.create_company_for_user(uuid, text, text, uuid) FROM anon;
REVOKE ALL ON FUNCTION public.create_company_for_user(uuid, text, text, uuid) FROM authenticated;
GRANT EXECUTE ON FUNCTION public.create_company_for_user(uuid, text, text, uuid) TO service_role;
NOTIFY pgrst, 'reload schema';
+153
View File
@@ -0,0 +1,153 @@
import { describe, expect, it } from 'vitest'
import type { PoolClient } from 'pg'
import { randomUUID } from 'node:crypto'
import { getClient, getPool, runAsServiceRole } from './setup'
import { insertAuthUser } from './fixtures'
/**
* create_company_for_user (migration 20260825120000): the service-role twin
* of create_company_with_owner used by the MCP tool gnubok_create_company and
* POST /api/v1/companies (issue #1814 PR 3).
*
* Locks in:
* - service role creates the company, owner membership, 1930 cash account
* and active-company preference for the explicit owner, and the trial
* grant trigger fires for it like for every other creation path
* - authenticated and anon callers are refused outright (42501): the
* function takes the owner as a plain argument, so exposing it to
* PostgREST roles would let anyone create companies for anyone
* - an unknown owner is refused (23503) and a foreign team is refused (42501)
*/
async function asRole<T>(
role: 'authenticated' | 'anon',
userId: string | null,
fn: (client: PoolClient) => Promise<T>,
): Promise<T> {
const client = await getClient()
try {
await client.query('BEGIN')
await client.query(`SELECT set_config('request.jwt.claims', $1, true)`, [
JSON.stringify(userId ? { sub: userId, role } : { role }),
])
await client.query(`SELECT set_config('request.jwt.claim.sub', $1, true)`, [userId ?? ''])
await client.query(`SELECT set_config('request.jwt.claim.role', $1, true)`, [role])
await client.query(`SET LOCAL ROLE ${role}`)
const result = await fn(client)
await client.query('COMMIT')
return result
} catch (error) {
await client.query('ROLLBACK').catch(() => {})
throw error
} finally {
client.release()
}
}
const CREATE = `SELECT public.create_company_for_user($1::uuid, $2, $3, $4::uuid) AS id`
describe('create_company_for_user.pg', () => {
it('creates company, owner membership, cash account and preference for the explicit owner', async () => {
const userId = await insertAuthUser()
const name = `Provisioned AB ${randomUUID().slice(0, 8)}`
const created = await getPool().query<{ id: string }>(CREATE, [userId, name, 'aktiebolag', null])
const companyId = created.rows[0]!.id
expect(companyId).toMatch(/^[0-9a-f-]{36}$/)
const company = await getPool().query(
`SELECT name, entity_type, created_by, team_id FROM public.companies WHERE id = $1`,
[companyId],
)
expect(company.rows[0]).toMatchObject({ name, entity_type: 'aktiebolag', created_by: userId, team_id: null })
const member = await getPool().query(
`SELECT role FROM public.company_members WHERE company_id = $1 AND user_id = $2`,
[companyId, userId],
)
expect(member.rows[0]).toMatchObject({ role: 'owner' })
const cash = await getPool().query(
`SELECT ledger_account, is_primary FROM public.cash_accounts WHERE company_id = $1`,
[companyId],
)
expect(cash.rows).toEqual([{ ledger_account: '1930', is_primary: true }])
const prefs = await getPool().query(
`SELECT active_company_id FROM public.user_preferences WHERE user_id = $1`,
[userId],
)
expect(prefs.rows[0]).toMatchObject({ active_company_id: companyId })
// The trial trigger on companies covers this path like every other one.
const grants = await getPool().query(
`SELECT count(*)::int AS n FROM public.capability_grants WHERE company_id = $1 AND source = 'trial'`,
[companyId],
)
expect(grants.rows[0]!.n).toBeGreaterThan(0)
})
it('runs the whole creation core under the real service_role, including the BAS chart seed', async () => {
// The MCP tool and POST /api/v1/companies run createCompanyCore with a
// service-role client. seed_chart_of_accounts is SECURITY DEFINER with a
// grant to `authenticated` only; this pins that service_role (PUBLIC
// execute, no REVOKE) can still call it, which unit tests cannot see.
const userId = await insertAuthUser()
const companyId = await runAsServiceRole(async (client) => {
const created = await client.query<{ id: string }>(CREATE, [userId, 'Service AB', 'aktiebolag', null])
const id = created.rows[0]!.id
await client.query(`SELECT public.seed_chart_of_accounts($1::uuid, 'aktiebolag')`, [id])
return id
})
const chart = await getPool().query<{ n: number }>(
`SELECT count(*)::int AS n FROM public.chart_of_accounts WHERE company_id = $1`,
[companyId],
)
// The starter chart is a curated subset (41 accounts on CI), not the full BAS list.
expect(chart.rows[0]!.n).toBeGreaterThan(0)
})
it('refuses an authenticated caller even for their own user id', async () => {
const userId = await insertAuthUser()
await expect(
asRole('authenticated', userId, async (client) => {
await client.query(CREATE, [userId, 'Sneaky AB', 'aktiebolag', null])
}),
).rejects.toMatchObject({ code: '42501' })
})
it('refuses an anon caller', async () => {
const userId = await insertAuthUser()
await expect(
asRole('anon', null, async (client) => {
await client.query(CREATE, [userId, 'Sneaky AB', 'aktiebolag', null])
}),
).rejects.toMatchObject({ code: '42501' })
})
it('refuses an unknown owner', async () => {
await expect(
getPool().query(CREATE, [randomUUID(), 'Ghost AB', 'aktiebolag', null]),
).rejects.toMatchObject({ code: '23503' })
})
it('refuses a team the owner is not a member of', async () => {
const owner = await insertAuthUser()
const other = await insertAuthUser()
const teamId = randomUUID()
await getPool().query(`INSERT INTO public.teams (id, name, created_by) VALUES ($1, 'Other', $2)`, [teamId, other])
await getPool().query(
`INSERT INTO public.team_members (team_id, user_id, role) VALUES ($1, $2, 'owner')`,
[teamId, other],
)
await expect(
getPool().query(CREATE, [owner, 'Wrong Team AB', 'aktiebolag', teamId]),
).rejects.toMatchObject({ code: '42501' })
})
it('rejects an unsupported entity type and an empty name', async () => {
const userId = await insertAuthUser()
await expect(getPool().query(CREATE, [userId, 'X HB', 'handelsbolag', null])).rejects.toThrow(/Invalid entity_type/)
await expect(getPool().query(CREATE, [userId, ' ', 'aktiebolag', null])).rejects.toThrow(/p_name is required/)
})
})