diff --git a/DECISIONS.md b/DECISIONS.md index cd3aaa80..d6219cc8 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1212,3 +1212,8 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/api/mcp-oauth/authorize/__tests__/route.test.ts b/app/api/mcp-oauth/authorize/__tests__/route.test.ts index 81319cc9..015e409f 100644 --- a/app/api/mcp-oauth/authorize/__tests__/route.test.ts +++ b/app/api/mcp-oauth/authorize/__tests__/route.test.ts @@ -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(/]*value="companies:write"[^>]*>/)?.[0] + expect(companiesWrite).toBeDefined() + expect(companiesWrite!).toContain('checked') + const transactionsWrite = html.match(/]*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' })) diff --git a/app/api/mcp-oauth/authorize/route.ts b/app/api/mcp-oauth/authorize/route.ts index ebb9473f..89d8c40a 100644 --- a/app/api/mcp-oauth/authorize/route.ts +++ b/app/api/mcp-oauth/authorize/route.ts @@ -283,6 +283,14 @@ export async function GET(request: Request) { // instructions"), which is the whole point of the consent step. const grantCeiling = new Set(parsed.scopes ?? ALL_SCOPES) const preChecked = new Set(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 diff --git a/app/api/v1/companies/__tests__/create.test.ts b/app/api/v1/companies/__tests__/create.test.ts new file mode 100644 index 00000000..4ea32da1 --- /dev/null +++ b/app/api/v1/companies/__tests__/create.test.ts @@ -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('@/lib/auth/api-keys') + return { + ...actual, + validateApiKey: vi.fn(), + createServiceClientNoCookies: vi.fn(), + } +}) + +vi.mock('@supabase/supabase-js', async () => { + const actual = await vi.importActual('@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 +const mockServiceClient = createServiceClientNoCookies as ReturnType + +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> = { + 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 = {}): 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[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) + }) +}) diff --git a/app/api/v1/companies/route.ts b/app/api/v1/companies/route.ts index 4793fc4f..bd784811 100644 --- a/app/api/v1/companies/route.ts +++ b/app/api/v1/companies/route.ts @@ -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) diff --git a/extensions/general/mcp-server/__tests__/connect-links.test.ts b/extensions/general/mcp-server/__tests__/connect-links.test.ts new file mode 100644 index 00000000..a5baff59 --- /dev/null +++ b/extensions/general/mcp-server/__tests__/connect-links.test.ts @@ -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> = { + 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 + 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 + 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 + 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 + 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 + expect(result.available).toBe(false) + expect(result.connect_url).toBeNull() + }) +}) diff --git a/extensions/general/mcp-server/__tests__/create-company.test.ts b/extensions/general/mcp-server/__tests__/create-company.test.ts new file mode 100644 index 00000000..085350be --- /dev/null +++ b/extensions/general/mcp-server/__tests__/create-company.test.ts @@ -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> = { + 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 + + expect(result.created).toBe(false) + expect(result.requires_confirmation).toBe(true) + const preview = result.preview as Record + 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 + + 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] + expect(input.settings).toMatchObject({ moms_period: 'quarterly', vat_registered: true, company_name: 'Testbolaget AB' }) + expect((result.next as Record).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).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' }) + }) +}) diff --git a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts index 31ca110e..f13b8ba3 100644 --- a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts +++ b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts @@ -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) }) }) diff --git a/extensions/general/mcp-server/company-routing.ts b/extensions/general/mcp-server/company-routing.ts index 2ad1f592..a026af7d 100644 --- a/extensions/general/mcp-server/company-routing.ts +++ b/extensions/general/mcp-server/company-routing.ts @@ -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', ]) /** diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index cdf3dd25..b7961b32 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -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 { + 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 = { 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 { '', ...(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.', diff --git a/extensions/general/mcp-server/skills/index.ts b/extensions/general/mcp-server/skills/index.ts index 08ed3a0b..a15c7a08 100644 --- a/extensions/general/mcp-server/skills/index.ts +++ b/extensions/general/mcp-server/skills/index.ts @@ -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)` diff --git a/extensions/general/mcp-server/skills/onboarding.ts b/extensions/general/mcp-server/skills/onboarding.ts new file mode 100644 index 00000000..670d4cde --- /dev/null +++ b/extensions/general/mcp-server/skills/onboarding.ts @@ -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', +} diff --git a/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap b/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap index 6425f941..c066a402 100644 --- a/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap +++ b/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap @@ -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", diff --git a/lib/auth/api-keys.ts b/lib/auth/api-keys.ts index 452d6dcd..46fb58ff 100644 --- a/lib/auth/api-keys.ts +++ b/lib/auth/api-keys.ts @@ -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 = { // 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 diff --git a/lib/auth/scopes.ts b/lib/auth/scopes.ts index 32eca03c..4b72398d 100644 --- a/lib/auth/scopes.ts +++ b/lib/auth/scopes.ts @@ -38,6 +38,8 @@ export const V1_PUBLIC_ENDPOINTS: ReadonlyArray = [ export const V1_ENDPOINT_SCOPES: Record = { // 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). diff --git a/lib/company/__tests__/onboarding-input.test.ts b/lib/company/__tests__/onboarding-input.test.ts new file mode 100644 index 00000000..40f25426 --- /dev/null +++ b/lib/company/__tests__/onboarding-input.test.ts @@ -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() + }) +}) diff --git a/lib/company/actions.ts b/lib/company/actions.ts index c3e326be..20524312 100644 --- a/lib/company/actions.ts +++ b/lib/company/actions.ts @@ -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), - ) - } 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 { diff --git a/lib/company/create-company.ts b/lib/company/create-company.ts new file mode 100644 index 00000000..33d2518b --- /dev/null +++ b/lib/company/create-company.ts @@ -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 + 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 { + // 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), + ) + } catch (deadlineError) { + await rollback('tax deadline generation failed', deadlineError) + return { error: COMPANY_CREATION_ERRORS.deadlines_failed } + } + + return { companyId: newCompanyId } +} diff --git a/lib/company/onboarding-input.ts b/lib/company/onboarding-input.ts new file mode 100644 index 00000000..8eddf0dc --- /dev/null +++ b/lib/company/onboarding-input.ts @@ -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 + +export type CompanySetupPlan = + | { + ok: true + input: Omit + /** 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 = { + 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 +} diff --git a/lib/entitlements/__tests__/capability-maps.test.ts b/lib/entitlements/__tests__/capability-maps.test.ts index 101f7f39..986f6ea9 100644 --- a/lib/entitlements/__tests__/capability-maps.test.ts +++ b/lib/entitlements/__tests__/capability-maps.test.ts @@ -22,6 +22,9 @@ const DISPATCH_ONLY_MCP_TOOLS = new Set([ '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. diff --git a/lib/entitlements/keys.ts b/lib/entitlements/keys.ts index a8027760..38854c6b 100644 --- a/lib/entitlements/keys.ts +++ b/lib/entitlements/keys.ts @@ -89,6 +89,9 @@ export const MCP_TOOL_CAPABILITY_MAP: Readonly- 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. --- @@ -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] diff --git a/skills/accounted-api/references/core.md b/skills/accounted-api/references/core.md index 7c0899f6..28e8e2b1 100644 --- a/skills/accounted-api/references/core.md +++ b/skills/accounted-api/references/core.md @@ -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.** diff --git a/supabase/migrations/20260825120000_create_company_for_user.sql b/supabase/migrations/20260825120000_create_company_for_user.sql new file mode 100644 index 00000000..0f57e47e --- /dev/null +++ b/supabase/migrations/20260825120000_create_company_for_user.sql @@ -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'; diff --git a/tests/pg/create-company-for-user.pg.test.ts b/tests/pg/create-company-for-user.pg.test.ts new file mode 100644 index 00000000..26c73722 --- /dev/null +++ b/tests/pg/create-company-for-user.pg.test.ts @@ -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( + role: 'authenticated' | 'anon', + userId: string | null, + fn: (client: PoolClient) => Promise, +): Promise { + 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/) + }) +})