feat(onboarding): company setup from the conversation and POST /api/v1/companies (#1814 PR 3) (#1864)
* feat(onboarding): company setup from the conversation and POST /api/v1/companies Third PR of agent-first onboarding (#1814). Once connected, the agent can now set up a company end to end without the web wizard, and partner platforms can provision companies over REST. - create_company_for_user: service-role-only SECURITY DEFINER twin of create_company_with_owner taking the owner explicitly (service clients have no auth.uid()). pg-real test covers creation, role gating, unknown owner and foreign team. - lib/company/create-company.ts: the wizard's creation sequence (org number, TIC snapshot, BAS chart, settings, first fiscal period, tax deadlines, rollback) extracted into createCompanyCore; the Server Action delegates to it, behaviour unchanged. - lib/company/onboarding-input.ts: one Zod schema + planner for the agent/API paths; a VAT-registered company without moms_period is refused (a missing period silently yields zero VAT deadlines). - MCP: gnubok_create_company (two-phase: preview, then confirm=true; companies:write, company-independent), gnubok_connect_bank and gnubok_connect_skatteverket (status + the browser link, gated on bank_sync / skatteverket, search-only in the catalog), the "onboarding" skill, and initialize instructions pointing at it. - Consent page pre-ticks companies:write for an account with no company yet, so the setup does not dead-end on insufficient scope after signup. - POST /api/v1/companies (companies:write, dry-run aware) on the same core; scope map, registry, spec snapshot and the generated API skill updated. - tools/list payload ceiling raised 59.95K -> 60.4K for the one new default-catalog tool (documented in the guard). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6 * fix(onboarding): explicit f_skatt, org number when VAT-registered, EF first year ends 31 Dec Review findings on #1864 (Swedish compliance review): - f_skatt is required, never defaulted to approved (SE-R-005 risk). - org_number is required when vat_registered: the invoice momsregistreringsnummer derives from it (ML 17 kap 24 §). - An enskild firma's first fiscal year must end on 31 December and its start month is forced to 1 even with first_fiscal_year set, mirroring the wizard's own rule text (BFL 3 kap. 1 §). - POST /api/v1/companies no longer claims Idempotency-Key support (the wrapper only honours it on company-scoped routes). - pg-real: createCompanyCore's chart seed runs under the real service_role, which the unit tests could not prove. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6 * test(pg): starter chart has 41 accounts, assert non-empty The service_role chart-seed proof passed the part that mattered (no 42501 from seed_chart_of_accounts) and failed on a wrong row-count guess: the seeded chart is a curated starter set, not the full BAS list. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6 * fix(migrations): move create_company_for_user to 20260825120000 main gained 20260824170000_bulk_book_transactions_service_actor.sql with the same version while this branch was open; two files on one version abort every Supabase branch apply and the prod auto-apply. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6 * chore(api): refresh spec snapshot and generated skill after rebasing onto main Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6 * fix(mcp): flat create_company result, refuse localhost connect links, test hygiene CodeRabbit on #1864: the confirmed-create result was wrapped in the { data, next } envelope while its outputSchema promised top-level fields; it now returns the fields with next as a sibling. The two connect-link tools refuse to build a link when NEXT_PUBLIC_APP_URL is unset instead of handing a remote user a localhost URL. Tests clear mocks and the event bus in beforeEach. Not changed: the rollback already survives user_preferences.active_company_id (that FK is ON DELETE SET NULL since 20260331010000), and v1 error details stay in the surface's English developer convention. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
c121d27996
commit
31e0cd6e05
@@ -1212,3 +1212,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-25] WhatsApp instant received-signal = emoji reaction (U+2705) sent from the webhook, not an extra text message: reactions add no chat bubble so the one-combined-ack-per-burst design survives; best-effort and not persisted as an outbound row (cosmetic, like mark-read), gated on the CHAT_ALLOWED_MIME_TYPES allowlist so junk never earns a checkmark.
|
||||
[2026-08-24] MCP lazy authentication (#1814 PR 2) lists the FULL default tool catalog to anonymous clients and only gates tools/call: the agent has to be able to name a protected tool to trigger the 401 challenge that opens the Connect (and signup) prompt; listing only public tools would hide the trigger. Descriptions are public documentation anyway.
|
||||
[2026-08-24] Public (pre-auth) MCP tools are the three documentation tools only (search_tools, list_skills, load_skill); org-number lookup stays behind the challenge for now because the TIC lookup lives in another extension and cross-extension imports are forbidden.
|
||||
[2026-08-24] gnubok_create_company (#1814 PR 3) is a direct write with an explicit two-phase confirm (preview, then confirm=true) instead of a staged pending_operation: pending_operations rows are company-scoped and there is no company to attach the staging to before it exists.
|
||||
[2026-08-24] gnubok_connect_bank / gnubok_connect_skatteverket hand the user a browser link plus connection status instead of driving the PSD2 or Skatteverket flow from the MCP server: both extension handlers need a cookie session and BankID in a browser, and cross-extension imports are forbidden. The web app's /import?mode=psd2 and the skatteverket authorize route are the links.
|
||||
[2026-08-24] The MCP consent page pre-ticks companies:write for an account that has no company yet: that account is connecting in order to create a company, and a default that dead-ends on insufficient scope right after signup would be the worse default. Still an untickable checkbox, still bounded by the client's scope ceiling.
|
||||
[2026-08-25] gnubok_create_company / POST /api/v1/companies require f_skatt explicitly and org_number whenever vat_registered (review findings on #1864): a defaulted F-skatt approval or a VAT-registered company with no momsregistreringsnummer would flow straight into invoices (ML 17 kap 24 §, SE-R-005). Explicit beats convenient on a legal fact.
|
||||
[2026-08-25] An enskild firma's first fiscal year must end on 31 December in the programmatic setup paths, mirroring the wizard's own rule text: the calendar-year mandate (BFL 3 kap. 1 §) is not lifted by the first-year extension.
|
||||
|
||||
@@ -435,6 +435,20 @@ describe('account with no company yet (issue #1814)', () => {
|
||||
expect(supabase.from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('pre-ticks companies:write so the agent can create the company, other writes stay unticked', async () => {
|
||||
mocks.createClient.mockResolvedValue(buildSupabase({ id: 'user-1', email: 'ny@example.se' }))
|
||||
|
||||
const response = await GET(new Request(buildAuthorizeUrl(authorizeParams)))
|
||||
const html = await response.text()
|
||||
|
||||
const companiesWrite = html.match(/<input[^>]*value="companies:write"[^>]*>/)?.[0]
|
||||
expect(companiesWrite).toBeDefined()
|
||||
expect(companiesWrite!).toContain('checked')
|
||||
const transactionsWrite = html.match(/<input[^>]*value="transactions:write"[^>]*>/)?.[0]
|
||||
expect(transactionsWrite).toBeDefined()
|
||||
expect(transactionsWrite!).not.toContain('checked')
|
||||
})
|
||||
|
||||
it('POST still issues an authorization code', async () => {
|
||||
mocks.createClient.mockResolvedValue(buildSupabase({ id: 'user-1', email: 'ny@example.se' }))
|
||||
|
||||
|
||||
@@ -283,6 +283,14 @@ export async function GET(request: Request) {
|
||||
// instructions"), which is the whole point of the consent step.
|
||||
const grantCeiling = new Set<ApiKeyScope>(parsed.scopes ?? ALL_SCOPES)
|
||||
const preChecked = new Set<ApiKeyScope>(parsed.scopes ?? DEFAULT_OAUTH_SCOPES)
|
||||
// An account with no company is connecting in order to create one
|
||||
// (issue #1814): pre-tick the one write scope that gnubok_create_company
|
||||
// needs, so the agent-driven setup does not dead-end on insufficient scope
|
||||
// right after signup. Still a checkbox the user can untick, and still
|
||||
// bounded by the client's ceiling.
|
||||
if (!companyId && grantCeiling.has('companies:write')) {
|
||||
preChecked.add('companies:write')
|
||||
}
|
||||
const scopeCheckboxesHtml = renderScopeCheckboxes(preChecked, grantCeiling)
|
||||
|
||||
// Render consent page
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* POST /api/v1/companies (issue #1814 PR 3): programmatic company creation
|
||||
* for partner provisioning and agents. Same static-route context shape as the
|
||||
* GET tests (Next.js 16 passes `{ params: undefined }`).
|
||||
*/
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
|
||||
})
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createCompanyCore: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
|
||||
return {
|
||||
...actual,
|
||||
validateApiKey: vi.fn(),
|
||||
createServiceClientNoCookies: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@supabase/supabase-js', async () => {
|
||||
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
|
||||
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
|
||||
})
|
||||
|
||||
vi.mock('@/lib/company/create-company', () => ({
|
||||
createCompanyCore: (...args: unknown[]) => mocks.createCompanyCore(...args),
|
||||
}))
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { POST as createCompany } from '../route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
|
||||
const USER_ID = '930abb54-c5ef-4ae0-b274-30fb16e9a295'
|
||||
const TEAM_ID = '44444444-4444-4444-8444-444444444444'
|
||||
const COMPANY_ID = '55555555-5555-4555-8555-555555555555'
|
||||
|
||||
function makeSupabase(teamId: string | null) {
|
||||
const chain: Record<string, ReturnType<typeof vi.fn>> = {
|
||||
select: vi.fn(() => chain),
|
||||
eq: vi.fn(() => chain),
|
||||
order: vi.fn(() => chain),
|
||||
limit: vi.fn(() => chain),
|
||||
maybeSingle: vi.fn().mockResolvedValue({ data: teamId ? { team_id: teamId } : null, error: null }),
|
||||
}
|
||||
return {
|
||||
from: vi.fn(() => chain),
|
||||
rpc: vi.fn().mockResolvedValue({ data: COMPANY_ID, error: null }),
|
||||
}
|
||||
}
|
||||
|
||||
function makeRequest(body: unknown, headers: Record<string, string> = {}): Request {
|
||||
return new Request('https://x.test/api/v1/companies', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer test-fixture-not-a-real-key',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': 'idem-1',
|
||||
...headers,
|
||||
},
|
||||
body: typeof body === 'string' ? body : JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
type PostCtx = Parameters<typeof createCompany>[1]
|
||||
const staticRouteContext = () => ({ params: undefined } as unknown as PostCtx)
|
||||
|
||||
const validBody = {
|
||||
name: 'Acme AB',
|
||||
entity_type: 'aktiebolag',
|
||||
org_number: '5560000001',
|
||||
vat_registered: true,
|
||||
moms_period: 'quarterly',
|
||||
accounting_method: 'accrual',
|
||||
f_skatt: true,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: null,
|
||||
apiKeyId: 'key-1',
|
||||
apiKeyName: 'Partner key',
|
||||
scopes: ['companies:write', 'companies:read'],
|
||||
mode: 'live',
|
||||
})
|
||||
mockServiceClient.mockReturnValue(makeSupabase(TEAM_ID))
|
||||
mocks.createCompanyCore.mockImplementation(
|
||||
async (_client: unknown, _input: unknown, createRow: () => Promise<{ data: unknown; error: unknown }>) => {
|
||||
const { data } = await createRow()
|
||||
return { companyId: data as string }
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('POST /api/v1/companies', () => {
|
||||
it('returns 401 for a missing bearer token', async () => {
|
||||
const request = new Request('https://x.test/api/v1/companies', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(validBody),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
const res = await createCompany(request, staticRouteContext())
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 403 without companies:write', async () => {
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: null,
|
||||
apiKeyId: 'key-1',
|
||||
apiKeyName: 'Read key',
|
||||
scopes: ['companies:read'],
|
||||
mode: 'live',
|
||||
})
|
||||
const res = await createCompany(makeRequest(validBody), staticRouteContext())
|
||||
expect(res.status).toBe(403)
|
||||
expect(mocks.createCompanyCore).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 for a VAT-registered company without a moms period', async () => {
|
||||
const res = await createCompany(makeRequest({ ...validBody, moms_period: undefined }), staticRouteContext())
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(JSON.stringify(body)).toContain('moms_period')
|
||||
expect(mocks.createCompanyCore).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 for a body that is not JSON', async () => {
|
||||
const res = await createCompany(makeRequest('not json'), staticRouteContext())
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('creates the company through the service-role RPC for the key user and returns 201', async () => {
|
||||
const supabase = makeSupabase(TEAM_ID)
|
||||
mockServiceClient.mockReturnValue(supabase)
|
||||
|
||||
const res = await createCompany(makeRequest(validBody), staticRouteContext())
|
||||
expect(res.status).toBe(201)
|
||||
const body = await res.json()
|
||||
expect(body.data).toMatchObject({
|
||||
id: COMPANY_ID,
|
||||
name: 'Acme AB',
|
||||
entity_type: 'aktiebolag',
|
||||
org_number: '5560000001',
|
||||
vat_registered: true,
|
||||
moms_period: 'quarterly',
|
||||
team_id: TEAM_ID,
|
||||
})
|
||||
expect(body.data.fiscal_period.name).toContain('Räkenskapsår')
|
||||
expect(supabase.rpc).toHaveBeenCalledWith('create_company_for_user', {
|
||||
p_user_id: USER_ID,
|
||||
p_name: 'Acme AB',
|
||||
p_entity_type: 'aktiebolag',
|
||||
p_team_id: TEAM_ID,
|
||||
})
|
||||
})
|
||||
|
||||
it('previews without creating for a test-mode key (dry run)', async () => {
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: null,
|
||||
apiKeyId: 'key-1',
|
||||
apiKeyName: 'Test key',
|
||||
scopes: ['companies:write'],
|
||||
mode: 'test',
|
||||
})
|
||||
const res = await createCompany(makeRequest(validBody), staticRouteContext())
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.dry_run).toBe(true)
|
||||
expect(body.data.preview.name).toBe('Acme AB')
|
||||
expect(mocks.createCompanyCore).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps a creation failure to INTERNAL_ERROR', async () => {
|
||||
mocks.createCompanyCore.mockResolvedValue({ error: 'Kunde inte skapa kontoplan. Försök igen.' })
|
||||
const res = await createCompany(makeRequest(validBody), staticRouteContext())
|
||||
expect(res.status).toBe(500)
|
||||
})
|
||||
})
|
||||
@@ -10,15 +10,18 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { paginated } from '@/lib/api/v1/response'
|
||||
import { paginated, created } from '@/lib/api/v1/response'
|
||||
import {
|
||||
encodeDefaultCursor,
|
||||
parsePaginationParams,
|
||||
decodeDefaultCursor,
|
||||
} from '@/lib/api/v1/pagination'
|
||||
import { registerEndpoint, listEnvelope } from '@/lib/api/v1/registry'
|
||||
import { registerEndpoint, listEnvelope, dataEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse } from '@/lib/api/v1/errors'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { dryRunPreview } from '@/lib/api/v1/dry-run'
|
||||
import { createCompanyCore } from '@/lib/company/create-company'
|
||||
import { CompanySetupSchema, planCompanySetup } from '@/lib/company/onboarding-input'
|
||||
|
||||
const Company = z.object({
|
||||
id: z.string().uuid(),
|
||||
@@ -70,6 +73,165 @@ registerEndpoint({
|
||||
response: { success: CompaniesListResponse },
|
||||
})
|
||||
|
||||
const CreatedCompany = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
entity_type: z.enum(['enskild_firma', 'aktiebolag']),
|
||||
org_number: z.string().nullable(),
|
||||
vat_registered: z.boolean(),
|
||||
moms_period: z.enum(['monthly', 'quarterly', 'yearly']).nullable(),
|
||||
accounting_method: z.enum(['accrual', 'cash']),
|
||||
fiscal_period: z.object({ start_date: z.string(), end_date: z.string(), name: z.string() }),
|
||||
team_id: z.string().uuid().nullable(),
|
||||
})
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'companies.create',
|
||||
method: 'POST',
|
||||
path: '/api/v1/companies',
|
||||
summary: 'Create a company and set it up for bookkeeping.',
|
||||
description:
|
||||
'Creates a new company owned by the API key user (or attached to one of their teams) and sets it up in one call: ' +
|
||||
'owner membership, BAS chart of accounts for the company form, compliance settings, the first fiscal period and the ' +
|
||||
'automatic tax deadlines. A 30-day trial with every paid capability starts immediately. ' +
|
||||
'Intended for partner platforms provisioning client companies (byrå/vertical SaaS) and for agents onboarding a user.',
|
||||
useWhen:
|
||||
'A platform or agent needs to provision a company that does not exist in Accounted yet. The caller becomes its owner; invite the end customer afterwards.',
|
||||
doNotUseFor:
|
||||
'Companies that already exist (list them with GET /api/v1/companies), or changing settings on an existing company (PATCH /api/v1/companies/{companyId}/settings).',
|
||||
pitfalls: [
|
||||
'A VAT-registered company MUST send moms_period (monthly / quarterly / yearly); the request is refused otherwise, because a missing period silently produces zero VAT deadlines.',
|
||||
'Bookkeeping duty under BFL starts when the company exists with a fiscal period: do not create companies to try things out. Use a test-mode key (dry run) for that.',
|
||||
'Enskild firma always runs on the calendar year; fiscal_year_start_month is ignored for it.',
|
||||
'first_fiscal_year is only for a company in its first year (BFL 3 kap.: up to 18 months). Omit it for an established company.',
|
||||
'Not idempotent, and Idempotency-Key is not honoured on this company-less route: a retry after a network failure creates a second company. List GET /api/v1/companies before retrying.',
|
||||
'org_number is required for a VAT-registered company (the invoice momsregistreringsnummer derives from it), and f_skatt must be stated explicitly: F-skatt approval is never assumed.',
|
||||
],
|
||||
example: {
|
||||
request: {
|
||||
name: 'Acme AB',
|
||||
entity_type: 'aktiebolag',
|
||||
org_number: '5566778899',
|
||||
vat_registered: true,
|
||||
moms_period: 'quarterly',
|
||||
accounting_method: 'accrual',
|
||||
f_skatt: true,
|
||||
},
|
||||
response: {
|
||||
data: {
|
||||
id: '8fd5b1f4-…',
|
||||
name: 'Acme AB',
|
||||
entity_type: 'aktiebolag',
|
||||
org_number: '5566778899',
|
||||
vat_registered: true,
|
||||
moms_period: 'quarterly',
|
||||
accounting_method: 'accrual',
|
||||
fiscal_period: { start_date: '2026-01-01', end_date: '2026-12-31', name: 'Räkenskapsår 2026' },
|
||||
team_id: null,
|
||||
},
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'companies:write',
|
||||
risk: 'medium',
|
||||
idempotent: false,
|
||||
reversible: false,
|
||||
dryRunSupported: true,
|
||||
request: { body: CompanySetupSchema },
|
||||
response: { success: dataEnvelope(CreatedCompany), errorCodes: ['VALIDATION_ERROR', 'FORBIDDEN', 'INTERNAL_ERROR'] },
|
||||
})
|
||||
|
||||
export const POST = withApiV1('companies.create', async (request, ctx) => {
|
||||
let rawBody: unknown
|
||||
try {
|
||||
rawBody = await request.json()
|
||||
} catch {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'body', message: 'Body is not valid JSON.' },
|
||||
})
|
||||
}
|
||||
|
||||
const parsed = CompanySetupSchema.safeParse(rawBody)
|
||||
if (!parsed.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { issues: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })) },
|
||||
})
|
||||
}
|
||||
const setup = parsed.data
|
||||
|
||||
const plan = planCompanySetup(setup)
|
||||
if (!plan.ok) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'first_fiscal_year', message: plan.error },
|
||||
})
|
||||
}
|
||||
|
||||
// Team: explicit, else the caller's first (usually personal) team, same as
|
||||
// the web wizard. create_company_for_user re-checks membership.
|
||||
let teamId: string | null = setup.team_id ?? null
|
||||
if (!teamId) {
|
||||
const { data: membership } = await ctx.supabase
|
||||
.from('team_members')
|
||||
.select('team_id')
|
||||
.eq('user_id', ctx.userId)
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
teamId = (membership?.team_id as string | undefined) ?? null
|
||||
}
|
||||
|
||||
const shape = (id: string) => ({
|
||||
id,
|
||||
name: setup.name,
|
||||
entity_type: setup.entity_type,
|
||||
org_number: (plan.input.settings.org_number as string | null) ?? null,
|
||||
vat_registered: setup.vat_registered,
|
||||
moms_period: setup.vat_registered ? setup.moms_period ?? null : null,
|
||||
accounting_method: setup.accounting_method,
|
||||
fiscal_period: {
|
||||
start_date: plan.fiscalPeriod.startDate,
|
||||
end_date: plan.fiscalPeriod.endDate,
|
||||
name: plan.fiscalPeriod.name,
|
||||
},
|
||||
team_id: teamId,
|
||||
})
|
||||
|
||||
if (ctx.dryRun) {
|
||||
return dryRunPreview(shape('00000000-0000-4000-8000-000000000000'), {
|
||||
requestId: ctx.requestId,
|
||||
log: ctx.log,
|
||||
})
|
||||
}
|
||||
|
||||
const result = await createCompanyCore(ctx.supabase, plan.input, () =>
|
||||
ctx.supabase.rpc('create_company_for_user', {
|
||||
p_user_id: ctx.userId,
|
||||
p_name: setup.name,
|
||||
p_entity_type: setup.entity_type,
|
||||
p_team_id: teamId,
|
||||
}),
|
||||
)
|
||||
|
||||
if (result.error !== undefined) {
|
||||
if (result.error === 'org_number_invalid') {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'org_number', message: 'Invalid organisationsnummer.' },
|
||||
})
|
||||
}
|
||||
ctx.log.error('companies.create failed', { reason: result.error })
|
||||
return v1ErrorResponseFromCode('INTERNAL_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { message: result.error },
|
||||
})
|
||||
}
|
||||
|
||||
return created(shape(result.companyId), { requestId: ctx.requestId })
|
||||
})
|
||||
|
||||
export const GET = withApiV1('companies.list', async (request, ctx) => {
|
||||
const url = new URL(request.url)
|
||||
const { limit, cursor } = parsePaginationParams(url)
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { MCP_TOOL_CAPABILITY_MAP } from '@/lib/entitlements/keys'
|
||||
import { tools } from '../server'
|
||||
|
||||
// Onboarding connect-link tools (issue #1814 PR 3): status + the browser link
|
||||
// the user opens. Both flows need a cookie session and BankID in a browser,
|
||||
// so the tools never try to drive them.
|
||||
|
||||
const COMPANY_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const bankTool = tools.find((t) => t.name === 'gnubok_connect_bank')!
|
||||
const skvTool = tools.find((t) => t.name === 'gnubok_connect_skatteverket')!
|
||||
|
||||
function listClient(rows: unknown[] | null, error: unknown = null) {
|
||||
const chain: Record<string, ReturnType<typeof vi.fn>> = {
|
||||
select: vi.fn(() => chain),
|
||||
eq: vi.fn(() => chain),
|
||||
in: vi.fn(() => chain),
|
||||
order: vi.fn(() => chain),
|
||||
limit: vi.fn(() => chain),
|
||||
maybeSingle: vi.fn().mockResolvedValue({ data: rows?.[0] ?? null, error }),
|
||||
then: (resolve: (v: unknown) => void) => resolve({ data: rows, error }),
|
||||
}
|
||||
return { from: vi.fn(() => chain), chain }
|
||||
}
|
||||
|
||||
describe('onboarding connect-link tools', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.example.test')
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('are read-only companies:read tools gated on the capability their link needs', () => {
|
||||
expect(TOOL_SCOPE_MAP.gnubok_connect_bank).toBe('companies:read')
|
||||
expect(TOOL_SCOPE_MAP.gnubok_connect_skatteverket).toBe('companies:read')
|
||||
expect(MCP_TOOL_CAPABILITY_MAP.gnubok_connect_bank).toBe('bank_sync')
|
||||
expect(MCP_TOOL_CAPABILITY_MAP.gnubok_connect_skatteverket).toBe('skatteverket')
|
||||
expect(bankTool.annotations.readOnlyHint).toBe(true)
|
||||
expect(skvTool.annotations.readOnlyHint).toBe(true)
|
||||
})
|
||||
|
||||
it('bank: reports no connection and hands out the PSD2 import link', async () => {
|
||||
const { from, chain } = listClient([])
|
||||
const result = (await bankTool.execute({}, COMPANY_ID, 'user-1', { from } as never)) as Record<string, unknown>
|
||||
expect(result.connected).toBe(false)
|
||||
expect(result.connect_url).toBe('https://app.example.test/import?mode=psd2')
|
||||
expect(chain.eq).toHaveBeenCalledWith('company_id', COMPANY_ID)
|
||||
})
|
||||
|
||||
it('bank: reports an active connection', async () => {
|
||||
const { from } = listClient([
|
||||
{ id: 'c1', bank_name: 'Swedbank', status: 'active', created_at: '2026-08-01T00:00:00Z' },
|
||||
])
|
||||
const result = (await bankTool.execute({}, COMPANY_ID, 'user-1', { from } as never)) as Record<string, unknown>
|
||||
expect(result.connected).toBe(true)
|
||||
expect(result.connections).toEqual([
|
||||
{ connection_id: 'c1', bank: 'Swedbank', status: 'active', since: '2026-08-01T00:00:00Z' },
|
||||
])
|
||||
})
|
||||
|
||||
it('skatteverket: hands out the authorize link when enabled and not connected', async () => {
|
||||
vi.stubEnv('SKATTEVERKET_ENABLED', 'true')
|
||||
const { from } = listClient([])
|
||||
const result = (await skvTool.execute({}, COMPANY_ID, 'user-1', { from } as never)) as Record<string, unknown>
|
||||
expect(result.available).toBe(true)
|
||||
expect(result.connected).toBe(false)
|
||||
expect(result.connect_url).toBe(
|
||||
'https://app.example.test/api/extensions/ext/skatteverket/authorize?return_to=%2F'
|
||||
)
|
||||
})
|
||||
|
||||
it('skatteverket: reports connected with the token expiry', async () => {
|
||||
vi.stubEnv('SKATTEVERKET_ENABLED', 'true')
|
||||
const { from } = listClient([{ expires_at: '2026-12-01T00:00:00Z' }])
|
||||
const result = (await skvTool.execute({}, COMPANY_ID, 'user-1', { from } as never)) as Record<string, unknown>
|
||||
expect(result.connected).toBe(true)
|
||||
expect(result.token_expires_at).toBe('2026-12-01T00:00:00Z')
|
||||
})
|
||||
|
||||
it('refuses to hand out a link when NEXT_PUBLIC_APP_URL is not configured', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', '')
|
||||
const { from } = listClient([])
|
||||
await expect(bankTool.execute({}, COMPANY_ID, 'user-1', { from } as never)).rejects.toMatchObject({
|
||||
code: 'INTERNAL_ERROR',
|
||||
})
|
||||
})
|
||||
|
||||
it('skatteverket: says so when the integration is disabled on the installation', async () => {
|
||||
vi.stubEnv('SKATTEVERKET_ENABLED', 'false')
|
||||
const { from } = listClient([])
|
||||
const result = (await skvTool.execute({}, COMPANY_ID, 'user-1', { from } as never)) as Record<string, unknown>
|
||||
expect(result.available).toBe(false)
|
||||
expect(result.connect_url).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createCompanyCore: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/create-company', () => ({
|
||||
createCompanyCore: (...args: unknown[]) => mocks.createCompanyCore(...args),
|
||||
}))
|
||||
|
||||
import { tools } from '../server'
|
||||
import { isCompanyDependentTool } from '../company-routing'
|
||||
|
||||
const tool = tools.find((t) => t.name === 'gnubok_create_company')!
|
||||
const TEAM_ID = '44444444-4444-4444-8444-444444444444'
|
||||
const COMPANY_ID = '55555555-5555-4555-8555-555555555555'
|
||||
|
||||
function supabaseWithTeam(teamId: string | null) {
|
||||
const chain: Record<string, ReturnType<typeof vi.fn>> = {
|
||||
select: vi.fn(() => chain),
|
||||
eq: vi.fn(() => chain),
|
||||
order: vi.fn(() => chain),
|
||||
limit: vi.fn(() => chain),
|
||||
maybeSingle: vi.fn().mockResolvedValue({ data: teamId ? { team_id: teamId } : null, error: null }),
|
||||
}
|
||||
return {
|
||||
from: vi.fn(() => chain),
|
||||
rpc: vi.fn().mockResolvedValue({ data: COMPANY_ID, error: null }),
|
||||
}
|
||||
}
|
||||
|
||||
const setup = {
|
||||
name: 'Testbolaget AB',
|
||||
entity_type: 'aktiebolag',
|
||||
org_number: '556000-0001',
|
||||
vat_registered: true,
|
||||
moms_period: 'quarterly',
|
||||
accounting_method: 'accrual',
|
||||
f_skatt: true,
|
||||
}
|
||||
|
||||
describe('gnubok_create_company', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
it('is a companies:write, company-independent write tool', () => {
|
||||
expect(tool).toBeDefined()
|
||||
expect(TOOL_SCOPE_MAP.gnubok_create_company).toBe('companies:write')
|
||||
expect(isCompanyDependentTool('gnubok_create_company')).toBe(false)
|
||||
expect(tool.annotations.readOnlyHint).toBe(false)
|
||||
expect(tool.annotations.destructiveHint).toBe(false)
|
||||
})
|
||||
|
||||
it('previews without creating when confirm is not true', async () => {
|
||||
const supabase = supabaseWithTeam(TEAM_ID)
|
||||
const result = (await tool.execute(setup, '', 'user-1', supabase as never)) as Record<string, unknown>
|
||||
|
||||
expect(result.created).toBe(false)
|
||||
expect(result.requires_confirmation).toBe(true)
|
||||
const preview = result.preview as Record<string, unknown>
|
||||
expect(preview.org_number).toBe('5560000001')
|
||||
expect(preview.vat_number).toBe('SE556000000101')
|
||||
expect(preview.team_id).toBe(TEAM_ID)
|
||||
expect(preview.fiscal_period).toMatchObject({ name: expect.stringContaining('Räkenskapsår') })
|
||||
expect(mocks.createCompanyCore).not.toHaveBeenCalled()
|
||||
expect(supabase.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates through the service-role RPC for the key user with confirm=true', async () => {
|
||||
const supabase = supabaseWithTeam(TEAM_ID)
|
||||
mocks.createCompanyCore.mockImplementation(
|
||||
async (_client: unknown, _input: unknown, createRow: () => Promise<{ data: unknown; error: unknown }>) => {
|
||||
const { data } = await createRow()
|
||||
return { companyId: data as string }
|
||||
}
|
||||
)
|
||||
|
||||
const result = (await tool.execute(
|
||||
{ ...setup, confirm: true },
|
||||
'',
|
||||
'user-1',
|
||||
supabase as never
|
||||
)) as Record<string, unknown>
|
||||
|
||||
expect(result.created).toBe(true)
|
||||
expect(result.company_id).toBe(COMPANY_ID)
|
||||
expect(supabase.rpc).toHaveBeenCalledWith('create_company_for_user', {
|
||||
p_user_id: 'user-1',
|
||||
p_name: 'Testbolaget AB',
|
||||
p_entity_type: 'aktiebolag',
|
||||
p_team_id: TEAM_ID,
|
||||
})
|
||||
const [, input] = mocks.createCompanyCore.mock.calls[0] as [unknown, Record<string, unknown>]
|
||||
expect(input.settings).toMatchObject({ moms_period: 'quarterly', vat_registered: true, company_name: 'Testbolaget AB' })
|
||||
expect((result.next as Record<string, unknown>).tool).toBe('gnubok_load_skill')
|
||||
})
|
||||
|
||||
it('refuses a VAT-registered company without a moms period before touching the database', async () => {
|
||||
const supabase = supabaseWithTeam(TEAM_ID)
|
||||
await expect(
|
||||
tool.execute({ ...setup, moms_period: undefined, confirm: true }, '', 'user-1', supabase as never)
|
||||
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', message: expect.stringContaining('moms_period') })
|
||||
expect(supabase.rpc).not.toHaveBeenCalled()
|
||||
expect(mocks.createCompanyCore).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses an explicit team_id over the default team', async () => {
|
||||
const supabase = supabaseWithTeam(TEAM_ID)
|
||||
const other = '66666666-6666-4666-8666-666666666666'
|
||||
const result = (await tool.execute({ ...setup, team_id: other }, '', 'user-1', supabase as never)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
expect((result.preview as Record<string, unknown>).team_id).toBe(other)
|
||||
expect(supabase.from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces a creation failure as a coded error', async () => {
|
||||
const supabase = supabaseWithTeam(null)
|
||||
mocks.createCompanyCore.mockResolvedValue({ error: 'Kunde inte skapa kontoplan. Försök igen.' })
|
||||
await expect(
|
||||
tool.execute({ ...setup, confirm: true }, '', 'user-1', supabase as never)
|
||||
).rejects.toMatchObject({ code: 'COMPANY_CREATE_FAILED' })
|
||||
})
|
||||
})
|
||||
@@ -209,9 +209,16 @@ describe('tools/list payload size guard', () => {
|
||||
// start instead of mid-task. The runtime block is emitted only for
|
||||
// companies with a connection; this cost is the outputSchema contract
|
||||
// (~140 tokens), already trimmed to two short description strings.
|
||||
// * 60.2K to 60.7K with gnubok_create_company (issue #1814 PR 3): a
|
||||
// default-catalog tool by necessity, since a client that has not
|
||||
// connected yet can only call what tools/list shows and this is the
|
||||
// first protected call of agent-driven onboarding. Its contract was
|
||||
// trimmed to bare property names first (the two connect-link tools
|
||||
// are search-only); headroom before the change was ~0 after the skatteverket_connection bump, so even the
|
||||
// bare contract crossed by ~420.
|
||||
// Long-term answer to growth is leaning harder on gnubok_search_tools: if this
|
||||
// fires again, prefer trimming descriptions or making a tool opt-in via search
|
||||
// before bumping further.
|
||||
expect(approxTokens).toBeLessThan(60_200)
|
||||
expect(approxTokens).toBeLessThan(60_700)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,6 +10,8 @@ const COMPANY_INDEPENDENT_TOOLS = new Set([
|
||||
'gnubok_list_skills',
|
||||
'gnubok_load_skill',
|
||||
'gnubok_list_companies',
|
||||
// Creates the company: by definition it runs before one exists.
|
||||
'gnubok_create_company',
|
||||
])
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,9 @@ import {
|
||||
type ApiKeyScope,
|
||||
} from '@/lib/auth/api-keys'
|
||||
import { checkRateLimit } from '@/lib/auth/rate-limit-http'
|
||||
import { getCanonicalBaseUrl } from '@/lib/api/v1/base-url'
|
||||
import { createCompanyCore } from '@/lib/company/create-company'
|
||||
import { CompanySetupSchema, planCompanySetup } from '@/lib/company/onboarding-input'
|
||||
import {
|
||||
ANONYMOUS_METHODS,
|
||||
ANONYMOUS_RATE_LIMIT,
|
||||
@@ -157,7 +160,7 @@ import {
|
||||
hashRequest,
|
||||
IdempotencyKeyReuseError,
|
||||
} from '@/lib/api/idempotency'
|
||||
import { toToolError, type NextActionHint } from './tool-result'
|
||||
import { toToolError, withNext, type NextActionHint } from './tool-result'
|
||||
import {
|
||||
addCompanyToNextHint,
|
||||
addCompanyToTopLevelNext,
|
||||
@@ -1433,6 +1436,42 @@ export function isDefaultCatalogTool(tool: { catalogVisibility?: 'default' | 'se
|
||||
return tool.catalogVisibility !== 'search'
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute origin for links the user opens in a browser. A deployment
|
||||
* without NEXT_PUBLIC_APP_URL falls back to localhost in getCanonicalBaseUrl,
|
||||
* which would hand a remote user an unusable link: refuse instead.
|
||||
*/
|
||||
function connectLinkBaseUrl(): string {
|
||||
if (!process.env.NEXT_PUBLIC_APP_URL) {
|
||||
throw Object.assign(
|
||||
new Error('NEXT_PUBLIC_APP_URL is not configured on this installation; cannot build a connect link'),
|
||||
{ code: 'INTERNAL_ERROR' }
|
||||
)
|
||||
}
|
||||
return getCanonicalBaseUrl()
|
||||
}
|
||||
|
||||
/**
|
||||
* The team a company created through the API/MCP path attaches to when the
|
||||
* caller does not name one: the user's first (usually the silent personal)
|
||||
* team, mirroring what the web wizard passes. null when the user has no
|
||||
* team at all; create_company_for_user then leaves team_id NULL.
|
||||
*/
|
||||
async function defaultTeamForUser(supabase: SupabaseClient, userId: string): Promise<string | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('team_members')
|
||||
.select('team_id')
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
if (error) {
|
||||
log.warn('default team lookup failed', { error: error.message })
|
||||
return null
|
||||
}
|
||||
return (data?.team_id as string | undefined) ?? null
|
||||
}
|
||||
|
||||
function paginatedSchema(itemsKey: string, itemSchema: Record<string, unknown> = { type: 'object' }) {
|
||||
return {
|
||||
type: 'object',
|
||||
@@ -2902,6 +2941,232 @@ export const tools: McpTool[] = [
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_create_company',
|
||||
title: 'Create Company',
|
||||
description:
|
||||
'Create a NEW company for the connected user, set up for bookkeeping (chart, settings, first fiscal period, tax deadlines; 30-day trial). Preview first (no confirm), read it back, then confirm=true. Ask, never assume: form, orgnr, VAT + moms period, method. Skill: onboarding.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
name: { type: 'string', minLength: 1, maxLength: 200 },
|
||||
entity_type: { type: 'string', enum: ['enskild_firma', 'aktiebolag'] },
|
||||
org_number: { type: 'string', description: '10 digits; required when VAT-registered' },
|
||||
vat_registered: { type: 'boolean' },
|
||||
moms_period: { type: 'string', enum: ['monthly', 'quarterly', 'yearly'], description: 'Required when vat_registered' },
|
||||
accounting_method: { type: 'string', enum: ['accrual', 'cash'] },
|
||||
f_skatt: { type: 'boolean' },
|
||||
fiscal_year_start_month: { type: 'integer', minimum: 1, maximum: 12 },
|
||||
first_fiscal_year: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: { start: { type: 'string' }, end: { type: 'string' } },
|
||||
required: ['start', 'end'],
|
||||
description: 'YYYY-MM-DD; first fiscal year only',
|
||||
},
|
||||
address_line1: { type: 'string' },
|
||||
postal_code: { type: 'string' },
|
||||
city: { type: 'string' },
|
||||
team_id: { type: 'string', format: 'uuid' },
|
||||
confirm: { type: 'boolean', description: 'true creates; omitted = preview' },
|
||||
},
|
||||
required: ['name', 'entity_type', 'vat_registered', 'accounting_method', 'f_skatt'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
created: { type: 'boolean' },
|
||||
requires_confirmation: { type: 'boolean' },
|
||||
company_id: { type: 'string' },
|
||||
preview: { type: 'object' },
|
||||
next: { type: 'object' },
|
||||
message: { type: 'string' },
|
||||
},
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, _companyId, userId, supabase) {
|
||||
const { confirm, ...setup } = args
|
||||
const parsed = CompanySetupSchema.safeParse(setup)
|
||||
if (!parsed.success) {
|
||||
const detail = parsed.error.issues
|
||||
.map((issue) => `${issue.path.join('.') || 'input'}: ${issue.message}`)
|
||||
.join('; ')
|
||||
throw Object.assign(new Error(`Invalid company setup: ${detail}`), { code: 'VALIDATION_ERROR' })
|
||||
}
|
||||
const plan = planCompanySetup(parsed.data)
|
||||
if (!plan.ok) {
|
||||
throw Object.assign(new Error(`Invalid fiscal period: ${plan.error}`), { code: 'VALIDATION_ERROR' })
|
||||
}
|
||||
|
||||
const teamId = parsed.data.team_id ?? (await defaultTeamForUser(supabase, userId))
|
||||
const preview = {
|
||||
name: parsed.data.name,
|
||||
entity_type: parsed.data.entity_type,
|
||||
org_number: (plan.input.settings.org_number as string | null) ?? null,
|
||||
vat_registered: parsed.data.vat_registered,
|
||||
vat_number: (plan.input.settings.vat_number as string | null) ?? null,
|
||||
moms_period: parsed.data.vat_registered ? parsed.data.moms_period ?? null : null,
|
||||
accounting_method: parsed.data.accounting_method,
|
||||
f_skatt: parsed.data.f_skatt,
|
||||
fiscal_period: plan.fiscalPeriod,
|
||||
team_id: teamId,
|
||||
}
|
||||
|
||||
if (confirm !== true) {
|
||||
return {
|
||||
created: false,
|
||||
requires_confirmation: true,
|
||||
preview,
|
||||
message:
|
||||
'Nothing was created. Read the preview back to the user (especially the fiscal period dates and the VAT setup), then call gnubok_create_company again with the same arguments and confirm=true.',
|
||||
}
|
||||
}
|
||||
|
||||
const result = await createCompanyCore(supabase, plan.input, () =>
|
||||
supabase.rpc('create_company_for_user', {
|
||||
p_user_id: userId,
|
||||
p_name: parsed.data.name,
|
||||
p_entity_type: parsed.data.entity_type,
|
||||
p_team_id: teamId,
|
||||
})
|
||||
)
|
||||
if (result.error !== undefined) {
|
||||
const code = result.error === 'org_number_invalid' ? 'VALIDATION_ERROR' : 'COMPANY_CREATE_FAILED'
|
||||
throw Object.assign(new Error(result.error), { code })
|
||||
}
|
||||
|
||||
return {
|
||||
created: true,
|
||||
company_id: result.companyId,
|
||||
...preview,
|
||||
trial: 'A 30-day trial with every paid capability (bank sync, Skatteverket, AI, e-mail) is active from now.',
|
||||
message:
|
||||
'Company created and ready for bookkeeping. This connection uses it automatically from the next call. Remaining setup: bank connection, Skatteverket connection, and the first transactions.',
|
||||
next: {
|
||||
description: 'Load the onboarding skill for the remaining setup steps (bank, Skatteverket, first transactions).',
|
||||
tool: 'gnubok_load_skill',
|
||||
args: { slug: 'onboarding' },
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_connect_bank',
|
||||
title: 'Connect Bank',
|
||||
description:
|
||||
'Bank connection status plus the browser link where the user connects a bank (PSD2, BankID consent; must be logged in to Accounted there). Use after gnubok_create_company or when transactions are missing because no bank is connected.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
connected: { type: 'boolean' },
|
||||
connections: { type: 'array', items: { type: 'object' } },
|
||||
connect_url: { type: 'string' },
|
||||
instructions: { type: 'string' },
|
||||
},
|
||||
required: ['connected', 'connections', 'connect_url', 'instructions'],
|
||||
},
|
||||
catalogVisibility: 'search',
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(_args, companyId, _userId, supabase) {
|
||||
const { data, error } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('id, bank_name, status, created_at')
|
||||
.eq('company_id', companyId)
|
||||
.in('status', ['pending', 'pending_selection', 'active', 'expired', 'error'])
|
||||
.order('created_at', { ascending: false })
|
||||
if (error) throw error
|
||||
const connections = (data ?? []) as Array<{ id: string; bank_name: string | null; status: string; created_at: string }>
|
||||
const active = connections.filter((c) => c.status === 'active')
|
||||
const connectUrl = `${connectLinkBaseUrl()}/import?mode=psd2`
|
||||
return {
|
||||
connected: active.length > 0,
|
||||
connections: connections.map((c) => ({
|
||||
connection_id: c.id,
|
||||
bank: c.bank_name,
|
||||
status: c.status,
|
||||
since: c.created_at,
|
||||
})),
|
||||
connect_url: connectUrl,
|
||||
instructions:
|
||||
active.length > 0
|
||||
? 'At least one bank is connected and syncing. To add another bank, give the user the connect_url.'
|
||||
: 'Give the user the connect_url to open in their browser (they must be logged in to Accounted there). They pick their bank and approve with BankID; consent lasts up to 180 days and the first transactions arrive within a minute. Tell them to come back here when done, then continue with gnubok_list_uncategorized_transactions.',
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_connect_skatteverket',
|
||||
title: 'Connect Skatteverket',
|
||||
description:
|
||||
'Skatteverket connection status plus the browser link where the user authorises Accounted (BankID as firmatecknare; logged in to Accounted there). Enables skattekonto sync and filing of moms/AGI. Use after gnubok_create_company or when a filing tool reports no connection.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
available: { type: 'boolean' },
|
||||
connected: { type: 'boolean' },
|
||||
token_expires_at: { type: ['string', 'null'] },
|
||||
connect_url: { type: ['string', 'null'] },
|
||||
instructions: { type: 'string' },
|
||||
},
|
||||
required: ['available', 'connected', 'token_expires_at', 'connect_url', 'instructions'],
|
||||
},
|
||||
catalogVisibility: 'search',
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(_args, companyId, _userId, supabase) {
|
||||
const enabled = process.env.SKATTEVERKET_ENABLED === 'true'
|
||||
const { data, error } = await supabase
|
||||
.from('skatteverket_tokens')
|
||||
.select('expires_at')
|
||||
.eq('company_id', companyId)
|
||||
.order('expires_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
if (error) throw error
|
||||
const token = data as { expires_at: string } | null
|
||||
const connected = Boolean(token)
|
||||
const connectUrl = `${connectLinkBaseUrl()}/api/extensions/ext/skatteverket/authorize?return_to=%2F`
|
||||
return {
|
||||
available: enabled,
|
||||
connected,
|
||||
token_expires_at: token?.expires_at ?? null,
|
||||
connect_url: enabled ? connectUrl : null,
|
||||
instructions: !enabled
|
||||
? 'The Skatteverket integration is not enabled on this installation. Declarations can still be downloaded as files and filed manually at skatteverket.se.'
|
||||
: connected
|
||||
? 'Skatteverket is connected. Skattekonto syncs automatically; momsdeklaration and AGI can be filed from here (each filing stages for approval).'
|
||||
: 'Give the user the connect_url to open in their browser (logged in to Accounted). Skatteverket asks them to identify with BankID as firmatecknare and approve the access; they land back in Accounted afterwards. Tell them to come back here when done.',
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_get_company_settings',
|
||||
title: 'Get Company Settings',
|
||||
@@ -18437,14 +18702,14 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
'',
|
||||
...(isAnonymous
|
||||
? [
|
||||
'NOT CONNECTED YET. Without an account you can call gnubok_search_tools, gnubok_list_skills and gnubok_load_skill. Every other tool needs the user to connect their Accounted account: calling one returns an authentication challenge that your client shows as a Connect prompt. A user who has no account creates one right there (BankID or e-mail, about a minute), and the call is then retried automatically. To start bookkeeping for a company that is not in Accounted yet, call gnubok_list_companies to trigger the connect step, then continue with the setup.',
|
||||
'NOT CONNECTED YET. Without an account you can call gnubok_search_tools, gnubok_list_skills and gnubok_load_skill. Every other tool needs the user to connect their Accounted account: calling one returns an authentication challenge that your client shows as a Connect prompt. A user who has no account creates one right there (BankID or e-mail, about a minute), and the call is then retried automatically. To set up bookkeeping for a company that is not in Accounted yet, load the "onboarding" skill (gnubok_load_skill) and follow it: gnubok_create_company is the first protected call and triggers the connect step.',
|
||||
'',
|
||||
]
|
||||
: []),
|
||||
'Discovery:',
|
||||
'• tools/list returns common tool schemas. Call gnubok_search_tools(query="…") for specialized tools: it ranks all capabilities; pass detail="name"|"summary"|"full" to control payload size.',
|
||||
'• gnubok_get_agent_briefing returns recommended_tools: ordered per-workflow tool loadouts (categorize_month, close_period, invoice_run, vat_declaration, payroll_month). If your harness defers tool loading, batch-load a whole workflow in one call (e.g. Claude Code ToolSearch select:a,b,c) instead of searching cluster by cluster.',
|
||||
`• This connection can work with every non-archived company the API-key user belongs to. Call gnubok_list_companies to discover company_id values. Omit company_id to use the API key default (${companyId ?? 'none yet: this account has no company; it must be created in the web app before company-data tools work'}); when selecting another company, repeat company_id on every company-data call, including approval.`,
|
||||
`• This connection can work with every non-archived company the API-key user belongs to. Call gnubok_list_companies to discover company_id values. Omit company_id to use the API key default (${companyId ?? 'none yet: this account has no company. Create it with gnubok_create_company (preview first, then confirm=true); the "onboarding" skill walks the whole setup'}); when selecting another company, repeat company_id on every company-data call, including approval.`,
|
||||
'• MCP resources use the API key default company. For a selected non-default company, call gnubok_get_agent_briefing with company_id instead of relying on Accounted://company/current or other company-data resources.',
|
||||
'• When the user asks "how do I do X" or you\'re unsure of the correct sequence (month-end close, VAT review, year-end, invoicing, payroll), call gnubok_list_skills first: domain workflows are documented as loadable skills with tool references.',
|
||||
'• When a tool is missing, a description misled you, a result looks wrong, or something worked unusually well, call gnubok_feedback (context + suggestion, optional tool_name). It is read by the product team and has fixed real bugs; include ids and what you expected. Rate-limited 1/min/key, so batch a session\'s findings into one call.',
|
||||
|
||||
@@ -9,6 +9,7 @@ import { bankReconciliationSkill } from './bank-reconciliation'
|
||||
import { kreditfakturaProcessSkill } from './kreditfaktura-process'
|
||||
import { customerOnboardingSkill } from './customer-onboarding'
|
||||
import { reconcileMonthSkill } from './reconcile-month'
|
||||
import { onboardingSkill } from './onboarding'
|
||||
import { loadAtomsAsSkills, loadReferenceById } from './atoms'
|
||||
|
||||
/** Static workflow skills the server ships with. Tier: 'workflow'. */
|
||||
@@ -22,6 +23,7 @@ export const workflowSkills: Skill[] = [
|
||||
kreditfakturaProcessSkill,
|
||||
customerOnboardingSkill,
|
||||
reconcileMonthSkill,
|
||||
onboardingSkill,
|
||||
]
|
||||
|
||||
/** @deprecated Use `workflowSkills` for the static set, or `loadAllSkills(supabase)`
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { Skill } from './types'
|
||||
|
||||
const body = `# Onboarding: set up a company in Accounted from the conversation
|
||||
|
||||
From "my company is not in Accounted yet" to a working ledger without the
|
||||
user opening the web app first. The only browser steps are the ones that
|
||||
legally need a human with BankID: connecting (creating the account),
|
||||
approving the bank consent, and authorising Skatteverket. Everything else
|
||||
happens here.
|
||||
|
||||
## When to use
|
||||
|
||||
- "Sätt upp bokföring för mitt AB / min enskilda firma"
|
||||
- "Jag har precis startat bolag, hur kommer jag igång?"
|
||||
- "Lägg till ett nytt bolag" (an existing user adding a second company)
|
||||
- A byrå/consultant onboarding a new client company (pass \`team_id\`)
|
||||
|
||||
## Step 0: connect
|
||||
|
||||
If this session is not connected yet, the first company-scoped call (for
|
||||
example \`gnubok_create_company\`) returns an authentication challenge that the
|
||||
client shows as a Connect prompt. Tell the user: "Klicka på Connect; har du
|
||||
inget konto skapar du det där (BankID eller e-post), det tar en minut." The
|
||||
call is retried automatically once connected. Do not send the user to the web
|
||||
app to sign up first.
|
||||
|
||||
## Step 1: gather the facts (ask, never assume)
|
||||
|
||||
Collect these before creating anything. The order mirrors the in-app wizard.
|
||||
|
||||
1. **Organisationsnummer** (10 digits). Required when the company is
|
||||
VAT-registered (the momsregistreringsnummer on every invoice derives from
|
||||
it) and strongly recommended otherwise: it drives Skatteverket/SIE exports. An enskild firma's
|
||||
org number is the owner's personnummer; that is fine to store here.
|
||||
2. **Company form**: \`aktiebolag\` or \`enskild_firma\`. Only these two are
|
||||
supported today; HB/KB/förening are not.
|
||||
3. **Company name** as registered.
|
||||
4. **F-skatt**: godkänd för F-skatt? Always ask; the tool refuses to assume it.
|
||||
A brand-new company may still be waiting for Skatteverket's approval (then
|
||||
false).
|
||||
5. **Fiscal year**: for enskild firma always the calendar year (do not ask);
|
||||
its first year may be shorter or up to 18 months but always ends 31 December.
|
||||
For an AB ask whether it is the calendar year or another 12-month period
|
||||
(\`fiscal_year_start_month\`). For a company in its FIRST year ask for the
|
||||
exact first fiscal year start and end (BFL 3 kap.: it may be shorter than
|
||||
12 months or up to 18 months) and pass \`first_fiscal_year\`.
|
||||
6. **VAT**: momsregistrerad? If yes, which period: \`monthly\`, \`quarterly\` or
|
||||
\`yearly\`. This is required when VAT-registered: without it Accounted
|
||||
generates no VAT deadlines at all, silently. If the user does not know,
|
||||
the rule of thumb: turnover under 1 MSEK may report yearly, under 40 MSEK
|
||||
quarterly, above that monthly; Skatteverket's registration decision states
|
||||
the actual period. Never guess it into the tool; ask.
|
||||
7. **Accounting method**: \`accrual\` (faktureringsmetoden) or \`cash\`
|
||||
(kontantmetoden / bokslutsmetoden). Cash is only allowed under 3 MSEK
|
||||
turnover and is common for small enskild firma; AB with invoices usually
|
||||
run accrual.
|
||||
|
||||
## Step 2: preview, confirm, create
|
||||
|
||||
Call \`gnubok_create_company\` WITHOUT \`confirm\` first. Read the preview back
|
||||
to the user in plain Swedish: company form, org number, the fiscal period
|
||||
dates, VAT setup (registered + period), method. Only after an explicit "ja"
|
||||
call it again with the same arguments and \`confirm: true\`.
|
||||
|
||||
What creation does in one step: company + owner membership, BAS chart of
|
||||
accounts for the company form, settings, the first fiscal period, and the
|
||||
automatic tax deadlines (moms, F-skatt, AGI, inkomstdeklaration). The 30-day
|
||||
trial with bank sync, Skatteverket, AI and e-mail starts immediately. This
|
||||
connection uses the new company automatically from the next call; no
|
||||
re-authentication.
|
||||
|
||||
## Step 3: connect the bank
|
||||
|
||||
Call \`gnubok_connect_bank\`. It reports existing connections and returns a
|
||||
\`connect_url\`. The user opens it in a browser where they are logged in to
|
||||
Accounted, picks the bank and approves with BankID (PSD2 consent, up to 180
|
||||
days). Transactions start syncing within a minute. If they prefer not to
|
||||
connect a bank, they can import bank statements as files in the web app
|
||||
instead; do not block on this step.
|
||||
|
||||
## Step 4: connect Skatteverket (optional but recommended)
|
||||
|
||||
Call \`gnubok_connect_skatteverket\`. Same pattern: the user opens the
|
||||
\`connect_url\`, identifies with BankID as firmatecknare at Skatteverket, and
|
||||
lands back in Accounted. This enables skattekonto sync and filing of
|
||||
momsdeklaration and arbetsgivardeklaration from here. Filing is never
|
||||
mandatory through Accounted: every declaration can be downloaded and filed
|
||||
manually.
|
||||
|
||||
## Step 5: first bookkeeping
|
||||
|
||||
Once transactions arrive: \`gnubok_list_uncategorized_transactions\` and the
|
||||
categorize flow (\`gnubok_suggest_categories\`, \`gnubok_categorize_transaction\`,
|
||||
approval). For a company with history in another system, offer the SIE
|
||||
import (\`gnubok_import_sie\` in the search catalog) before categorizing.
|
||||
|
||||
## Tools
|
||||
|
||||
- \`gnubok_create_company\`: preview (no confirm) then create (confirm=true)
|
||||
- \`gnubok_list_companies\`: see which companies this connection can reach
|
||||
- \`gnubok_connect_bank\`: status + connect link for PSD2 bank consent
|
||||
- \`gnubok_connect_skatteverket\`: status + connect link for Skatteverket
|
||||
- \`gnubok_get_agent_briefing\`: the company's settings and state once created
|
||||
- \`gnubok_list_uncategorized_transactions\`: the first real bookkeeping step
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- A VAT-registered company without a moms period is refused on purpose; do
|
||||
not work around it by claiming the company is not VAT-registered.
|
||||
- Do not create a company twice on a retry: check \`gnubok_list_companies\`
|
||||
if a create call was interrupted.
|
||||
- Bookkeeping duty starts when the company exists in Accounted with a fiscal
|
||||
period. Never create a company "to try things out" for a real
|
||||
organisation; use the sandbox in the web app for demos.
|
||||
`
|
||||
|
||||
export const onboardingSkill: Skill = {
|
||||
slug: 'onboarding',
|
||||
name: 'Onboarding: New Company Setup',
|
||||
summary:
|
||||
'Set up a company from the conversation: gather facts, preview and create with gnubok_create_company, then hand out the bank and Skatteverket connect links.',
|
||||
tags: ['onboarding', 'setup', 'company', 'bank', 'skatteverket', 'agent-first'],
|
||||
body,
|
||||
tier: 'workflow',
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `135`;
|
||||
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `136`;
|
||||
|
||||
exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-keys 1`] = `
|
||||
[
|
||||
@@ -79,6 +79,7 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
|
||||
"PATCH /api/v1/companies/:companyId/supplier-invoices/:id",
|
||||
"PATCH /api/v1/companies/:companyId/suppliers/:id",
|
||||
"PATCH /api/v1/companies/:companyId/webhooks/:id",
|
||||
"POST /api/v1/companies",
|
||||
"POST /api/v1/companies/:companyId/customers",
|
||||
"POST /api/v1/companies/:companyId/customers/bulk-create",
|
||||
"POST /api/v1/companies/:companyId/dimensions/:id/values",
|
||||
|
||||
@@ -28,7 +28,7 @@ export const API_KEY_SCOPES = {
|
||||
'payroll:write': { label: 'Löner: skriv', description: 'Skapa lönekörning, beräkna, generera AGI, logga körjournalresor' },
|
||||
// v1 REST API: added Phase 1
|
||||
'companies:read': { label: 'Företag: läs', description: 'Lista och visa företagsprofiler som API-nyckeln har tillgång till' },
|
||||
'companies:write': { label: 'Företag: skriv', description: 'Uppdatera företagsinställningar via stagade verktyg eller REST-endpointen PATCH /api/v1/companies/{companyId}/settings' },
|
||||
'companies:write': { label: 'Företag: skriv', description: 'Skapa nya företag och uppdatera företagsinställningar (gnubok_create_company, stagade verktyg, REST POST /api/v1/companies och PATCH /api/v1/companies/{companyId}/settings)' },
|
||||
'events:read': { label: 'Händelser: läs', description: 'Polla händelseloggen (event_log) som webhook-fallback' },
|
||||
'webhooks:manage': { label: 'Webhooks: hantera', description: 'Skapa, lista, uppdatera och radera webhook-prenumerationer' },
|
||||
'operations:read': { label: 'Operationer: läs', description: 'Hämta status för långkörande operationer (importer, bokslut, omvärdering)' },
|
||||
@@ -179,6 +179,9 @@ export const SCOPE_GROUPS = [
|
||||
export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
|
||||
// Companies
|
||||
gnubok_list_companies: 'companies:read',
|
||||
gnubok_create_company: 'companies:write',
|
||||
gnubok_connect_bank: 'companies:read',
|
||||
gnubok_connect_skatteverket: 'companies:read',
|
||||
gnubok_get_company_settings: 'companies:read',
|
||||
gnubok_update_company_settings: 'companies:write',
|
||||
// Transactions
|
||||
|
||||
@@ -38,6 +38,8 @@ export const V1_PUBLIC_ENDPOINTS: ReadonlyArray<string> = [
|
||||
export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
|
||||
// Companies
|
||||
'GET /api/v1/companies': 'companies:read',
|
||||
// Issue #1814: programmatic company creation (partner provisioning, agents).
|
||||
'POST /api/v1/companies': 'companies:write',
|
||||
'GET /api/v1/companies/:companyId': 'companies:read',
|
||||
// Issue #1348: company-settings write (same field set as the MCP tool
|
||||
// gnubok_update_company_settings; direct write, no staging).
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CompanySetupSchema, planCompanySetup } from '../onboarding-input'
|
||||
|
||||
const base = {
|
||||
name: 'Testbolaget AB',
|
||||
entity_type: 'aktiebolag' as const,
|
||||
org_number: '5560000001',
|
||||
vat_registered: true,
|
||||
moms_period: 'quarterly' as const,
|
||||
accounting_method: 'accrual' as const,
|
||||
f_skatt: true,
|
||||
}
|
||||
|
||||
describe('CompanySetupSchema', () => {
|
||||
it('accepts a complete aktiebolag setup', () => {
|
||||
expect(CompanySetupSchema.safeParse(base).success).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses a VAT-registered company without a moms_period (silent zero-deadline trap)', () => {
|
||||
const result = CompanySetupSchema.safeParse({ ...base, moms_period: undefined })
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.issues.some((i) => i.path.join('.') === 'moms_period')).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses a moms_period on a company that is not VAT-registered', () => {
|
||||
const result = CompanySetupSchema.safeParse({ ...base, vat_registered: false })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts a non-VAT company with no moms_period', () => {
|
||||
const result = CompanySetupSchema.safeParse({ ...base, vat_registered: false, moms_period: undefined })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses a VAT-registered company without an org number (invoice momsregistreringsnummer)', () => {
|
||||
const result = CompanySetupSchema.safeParse({ ...base, org_number: undefined })
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.issues.some((i) => i.path.join('.') === 'org_number')).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts a non-VAT company without an org number', () => {
|
||||
const result = CompanySetupSchema.safeParse({
|
||||
...base,
|
||||
org_number: undefined,
|
||||
vat_registered: false,
|
||||
moms_period: undefined,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses an omitted f_skatt: F-skatt approval is never assumed', () => {
|
||||
const result = CompanySetupSchema.safeParse({ ...base, f_skatt: undefined })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses an enskild firma first fiscal year that does not end on 31 December', () => {
|
||||
const result = CompanySetupSchema.safeParse({
|
||||
...base,
|
||||
entity_type: 'enskild_firma',
|
||||
first_fiscal_year: { start: '2026-03-15', end: '2027-06-30' },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.issues.some((i) => i.path.join('.') === 'first_fiscal_year.end')).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses a malformed organisationsnummer', () => {
|
||||
const result = CompanySetupSchema.safeParse({ ...base, org_number: '1234' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('planCompanySetup', () => {
|
||||
it('produces the wizard-shaped settings and a calendar-year period by default', () => {
|
||||
const plan = planCompanySetup(CompanySetupSchema.parse(base))
|
||||
expect(plan.ok).toBe(true)
|
||||
if (!plan.ok) return
|
||||
const year = new Date().getFullYear()
|
||||
expect(plan.fiscalPeriod.startDate).toBe(`${year}-01-01`)
|
||||
expect(plan.fiscalPeriod.endDate).toBe(`${year}-12-31`)
|
||||
expect(plan.input.settings).toMatchObject({
|
||||
entity_type: 'aktiebolag',
|
||||
company_name: 'Testbolaget AB',
|
||||
org_number: '5560000001',
|
||||
vat_registered: true,
|
||||
vat_number: 'SE556000000101',
|
||||
moms_period: 'quarterly',
|
||||
accounting_method: 'accrual',
|
||||
f_skatt: true,
|
||||
fiscal_year_start_month: 1,
|
||||
is_first_fiscal_year: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('forces enskild firma onto the calendar year regardless of the requested start month', () => {
|
||||
const plan = planCompanySetup(
|
||||
CompanySetupSchema.parse({ ...base, entity_type: 'enskild_firma', fiscal_year_start_month: 7 })
|
||||
)
|
||||
expect(plan.ok).toBe(true)
|
||||
if (!plan.ok) return
|
||||
expect(plan.input.settings.fiscal_year_start_month).toBe(1)
|
||||
expect(plan.fiscalPeriod.startDate.endsWith('-01-01')).toBe(true)
|
||||
})
|
||||
|
||||
it('uses the first fiscal year dates and derives the start month from its end', () => {
|
||||
const plan = planCompanySetup(
|
||||
CompanySetupSchema.parse({
|
||||
...base,
|
||||
first_fiscal_year: { start: '2026-03-15', end: '2027-06-30' },
|
||||
})
|
||||
)
|
||||
expect(plan.ok).toBe(true)
|
||||
if (!plan.ok) return
|
||||
expect(plan.fiscalPeriod).toMatchObject({ startDate: '2026-03-15', endDate: '2027-06-30' })
|
||||
expect(plan.fiscalPeriod.name).toContain('Första räkenskapsåret')
|
||||
expect(plan.input.settings.fiscal_year_start_month).toBe(7)
|
||||
expect(plan.input.settings.is_first_fiscal_year).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps an enskild firma on the calendar year through a first fiscal year ending 31 December', () => {
|
||||
const plan = planCompanySetup(
|
||||
CompanySetupSchema.parse({
|
||||
...base,
|
||||
entity_type: 'enskild_firma',
|
||||
first_fiscal_year: { start: '2026-03-15', end: '2026-12-31' },
|
||||
})
|
||||
)
|
||||
expect(plan.ok).toBe(true)
|
||||
if (!plan.ok) return
|
||||
expect(plan.fiscalPeriod).toMatchObject({ startDate: '2026-03-15', endDate: '2026-12-31' })
|
||||
expect(plan.input.settings.fiscal_year_start_month).toBe(1)
|
||||
})
|
||||
|
||||
it('carries f_skatt=false through instead of defaulting to approved', () => {
|
||||
const plan = planCompanySetup(CompanySetupSchema.parse({ ...base, f_skatt: false }))
|
||||
expect(plan.ok).toBe(true)
|
||||
if (!plan.ok) return
|
||||
expect(plan.input.settings.f_skatt).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a first fiscal year longer than 18 months', () => {
|
||||
const plan = planCompanySetup(
|
||||
CompanySetupSchema.parse({
|
||||
...base,
|
||||
first_fiscal_year: { start: '2026-01-01', end: '2027-12-31' },
|
||||
})
|
||||
)
|
||||
expect(plan.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves vat_number null and moms_period null for a non-VAT company', () => {
|
||||
const plan = planCompanySetup(
|
||||
CompanySetupSchema.parse({ ...base, vat_registered: false, moms_period: undefined })
|
||||
)
|
||||
expect(plan.ok).toBe(true)
|
||||
if (!plan.ok) return
|
||||
expect(plan.input.settings.vat_number).toBeNull()
|
||||
expect(plan.input.settings.moms_period).toBeNull()
|
||||
})
|
||||
})
|
||||
+21
-171
@@ -3,13 +3,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { setActiveCompany, CompanyContextError } from '@/lib/company/context'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
|
||||
import { normalizeVatNumber, isValidSwedishVatNumber, deriveSwedishVatNumber } from '@/lib/vat/vat-number'
|
||||
import {
|
||||
regenerateTaxDeadlinesForUser,
|
||||
toDeadlineSettings,
|
||||
} from '@/lib/tax/deadline-generator'
|
||||
import type { CompanySettingsForDeadlines } from '@/lib/tax/deadline-config'
|
||||
import { createCompanyCore } from '@/lib/company/create-company'
|
||||
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
@@ -101,174 +95,30 @@ async function createCompanyFromOnboardingImpl(params: {
|
||||
|
||||
const companyName = (params.settings.company_name as string | undefined) || 'Mitt företag'
|
||||
|
||||
// Org-number format validation. We intentionally do NOT enforce
|
||||
// uniqueness: the same org number may legitimately appear on multiple
|
||||
// companies (a separate test copy of your real company, or a consultant
|
||||
// and the owner each tracking the same entity). Tenant isolation
|
||||
// (RLS + company_id) is the real boundary, not org-number uniqueness.
|
||||
//
|
||||
// normalizeOrgNumber returns null for malformed input: we refuse rather
|
||||
// than storing a value that would break SIE/SRU exports later.
|
||||
const rawOrgNumber = params.settings.org_number as string | undefined
|
||||
const cleanedOrgNumber = normalizeOrgNumber(rawOrgNumber)
|
||||
if (rawOrgNumber && rawOrgNumber.trim() && !cleanedOrgNumber) {
|
||||
return { error: 'org_number_invalid' }
|
||||
}
|
||||
|
||||
// 1. Create company + owner membership atomically via RPC
|
||||
const { data: newCompanyId, error: companyError } = await supabase.rpc('create_company_with_owner', {
|
||||
p_name: companyName,
|
||||
p_entity_type: entityType,
|
||||
p_team_id: params.teamId,
|
||||
})
|
||||
|
||||
if (companyError || !newCompanyId) {
|
||||
console.error('[createCompanyFromOnboarding] company creation failed', companyError)
|
||||
return { error: 'Kunde inte skapa företag. Försök igen.' }
|
||||
}
|
||||
|
||||
// Helper: roll back the company if a subsequent step fails. Deletes in FK
|
||||
// order. Each delete is error-checked so a failed cleanup leaves a trace
|
||||
// instead of silently stranding partial company data behind a generic
|
||||
// "try again" message.
|
||||
const rollback = async (reason: string, err: unknown) => {
|
||||
console.error(`[createCompanyFromOnboarding] rolling back ${newCompanyId}: ${reason}`, err)
|
||||
const deletions: Array<[table: string, run: () => PromiseLike<{ error: unknown }>]> = [
|
||||
['company_settings', () => supabase.from('company_settings').delete().eq('company_id', newCompanyId)],
|
||||
['fiscal_periods', () => supabase.from('fiscal_periods').delete().eq('company_id', newCompanyId)],
|
||||
['chart_of_accounts', () => supabase.from('chart_of_accounts').delete().eq('company_id', newCompanyId)],
|
||||
['company_members', () => supabase.from('company_members').delete().eq('company_id', newCompanyId)],
|
||||
['companies', () => supabase.from('companies').delete().eq('id', newCompanyId)],
|
||||
]
|
||||
for (const [table, run] of deletions) {
|
||||
const { error: deleteError } = await run()
|
||||
if (deleteError) {
|
||||
console.error(
|
||||
`[createCompanyFromOnboarding] rollback delete failed for ${table} (company ${newCompanyId})`,
|
||||
deleteError,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror the normalized org_number onto the companies row so future
|
||||
// duplicate checks and cross-references are reliable. MUST be error-checked
|
||||
// and rolled back on failure: otherwise the freshly-created company would
|
||||
// exist without an org_number and the duplicate guard would never match it
|
||||
// for any future user (the very guard this code is enforcing).
|
||||
if (cleanedOrgNumber) {
|
||||
const { error: orgUpdateError } = await supabase
|
||||
.from('companies')
|
||||
.update({ org_number: cleanedOrgNumber })
|
||||
.eq('id', newCompanyId)
|
||||
if (orgUpdateError) {
|
||||
await rollback('org_number update failed', orgUpdateError)
|
||||
return { error: 'Kunde inte spara organisationsnummer. Försök igen.' }
|
||||
}
|
||||
}
|
||||
|
||||
// Persist whatever lookup data the wizard already gathered. Do NOT call
|
||||
// /profile here: that handler fans out to 13 Lens calls and the 5 s
|
||||
// timeout in tic-fetch.ts ate ~530 wasted calls in May before yielding
|
||||
// zero snapshots (every signup's /profile timed out, but the in-flight
|
||||
// upstream fetches still counted against quota). The agent build path
|
||||
// (app/(onboarding)/onboarding/agent/page.tsx) calls ensureTicSnapshot
|
||||
// with upgradeV1: true lazily, which is the right place: only companies
|
||||
// that actually reach agent onboarding spend the budget.
|
||||
if (params.ticLookup) {
|
||||
const { error: ticErr } = await supabase
|
||||
.from('companies')
|
||||
.update({
|
||||
tic_snapshot: params.ticLookup,
|
||||
tic_snapshot_fetched_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', newCompanyId)
|
||||
if (ticErr) {
|
||||
console.warn('[createCompanyFromOnboarding] tic snapshot persist failed', ticErr)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Seed chart of accounts
|
||||
const { error: coaError } = await supabase.rpc('seed_chart_of_accounts', {
|
||||
p_company_id: newCompanyId,
|
||||
p_entity_type: entityType,
|
||||
})
|
||||
if (coaError) {
|
||||
await rollback('COA seeding failed', coaError)
|
||||
return { error: 'Kunde inte skapa kontoplan. Försök igen.' }
|
||||
}
|
||||
|
||||
// 3. Save settings (strip UI-only and managed fields)
|
||||
const {
|
||||
id: _id,
|
||||
user_id: _uid,
|
||||
company_id: _cid,
|
||||
created_at: _ca,
|
||||
updated_at: _ua,
|
||||
is_first_fiscal_year: _ify,
|
||||
first_year_start: _fys,
|
||||
first_year_end: _fye,
|
||||
...settingsToSave
|
||||
} = params.settings
|
||||
|
||||
// Defence in depth: this upsert bypasses UpdateSettingsSchema, so never persist
|
||||
// a VAT number blind. Normalise to the canonical SE+12 form; if it isn't
|
||||
// structurally valid (e.g. the legacy SE+14 personnummer derivation), re-derive
|
||||
// it from the org number, falling back to null rather than storing a malformed
|
||||
// momsregistreringsnummer.
|
||||
if (typeof settingsToSave.vat_number === 'string' && settingsToSave.vat_number) {
|
||||
const normalized = normalizeVatNumber(settingsToSave.vat_number)
|
||||
settingsToSave.vat_number = isValidSwedishVatNumber(normalized)
|
||||
? normalized
|
||||
: deriveSwedishVatNumber(settingsToSave.org_number as string | null | undefined)
|
||||
}
|
||||
|
||||
const { error: settingsError } = await supabase
|
||||
.from('company_settings')
|
||||
.upsert(
|
||||
{
|
||||
...settingsToSave,
|
||||
company_id: newCompanyId,
|
||||
onboarding_complete: true,
|
||||
onboarding_step: 4,
|
||||
},
|
||||
{ onConflict: 'company_id' },
|
||||
)
|
||||
|
||||
if (settingsError) {
|
||||
await rollback('settings upsert failed', settingsError)
|
||||
return { error: 'Kunde inte spara inställningar. Försök igen.' }
|
||||
}
|
||||
|
||||
// 4. Create fiscal period
|
||||
const { error: periodError } = await supabase.from('fiscal_periods').upsert(
|
||||
// Steps 1-5 (company + owner via RPC, org number, TIC snapshot, chart,
|
||||
// settings, fiscal period, tax deadlines, with rollback) are shared with
|
||||
// the MCP and v1 creation paths: lib/company/create-company.ts.
|
||||
const created = await createCompanyCore(
|
||||
supabase,
|
||||
{
|
||||
company_id: newCompanyId,
|
||||
name: params.fiscalPeriod.name,
|
||||
period_start: params.fiscalPeriod.startDate,
|
||||
period_end: params.fiscalPeriod.endDate,
|
||||
entityType,
|
||||
companyName,
|
||||
orgNumber: params.settings.org_number as string | undefined,
|
||||
settings: params.settings,
|
||||
fiscalPeriod: params.fiscalPeriod,
|
||||
ticLookup: params.ticLookup,
|
||||
},
|
||||
{ onConflict: 'company_id,period_start,period_end' },
|
||||
() =>
|
||||
supabase.rpc('create_company_with_owner', {
|
||||
p_name: companyName,
|
||||
p_entity_type: entityType,
|
||||
p_team_id: params.teamId,
|
||||
}),
|
||||
)
|
||||
|
||||
if (periodError) {
|
||||
await rollback('fiscal period upsert failed', periodError)
|
||||
return { error: 'Kunde inte skapa räkenskapsår. Försök igen.' }
|
||||
}
|
||||
|
||||
// 5. Create the automatic tax deadlines while the onboarding data is still
|
||||
// available. Treat this as part of company creation so a new company never
|
||||
// starts in the broken state where valid settings exist without deadlines.
|
||||
try {
|
||||
await regenerateTaxDeadlinesForUser(
|
||||
supabase,
|
||||
newCompanyId,
|
||||
toDeadlineSettings(settingsToSave as Partial<CompanySettingsForDeadlines>),
|
||||
)
|
||||
} catch (deadlineError) {
|
||||
await rollback('tax deadline generation failed', deadlineError)
|
||||
return { error: 'Kunde inte skapa skattedeadlines. Försök igen.' }
|
||||
if (created.error !== undefined) {
|
||||
return { error: created.error }
|
||||
}
|
||||
const newCompanyId = created.companyId
|
||||
|
||||
// 6. Set as active company
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
|
||||
import { normalizeVatNumber, isValidSwedishVatNumber, deriveSwedishVatNumber } from '@/lib/vat/vat-number'
|
||||
import { regenerateTaxDeadlinesForUser, toDeadlineSettings } from '@/lib/tax/deadline-generator'
|
||||
import type { CompanySettingsForDeadlines } from '@/lib/tax/deadline-config'
|
||||
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
|
||||
import type { EntityType } from '@/types'
|
||||
|
||||
/**
|
||||
* The one company-creation sequence, shared by the web wizard (Server
|
||||
* Action, cookie session, create_company_with_owner), the MCP tool
|
||||
* gnubok_create_company and POST /api/v1/companies (service client,
|
||||
* create_company_for_user). Issue #1814 PR 3.
|
||||
*
|
||||
* All steps after the company row exists roll back on failure so a company
|
||||
* never survives half-configured: bookkeeping duty under BFL starts the
|
||||
* moment the tenant is real, and a company without a chart, a fiscal period
|
||||
* or its tax deadlines is worse than no company.
|
||||
*
|
||||
* The caller supplies `createCompanyRow`, the RPC call that inserts the
|
||||
* company + owner membership: which RPC is right depends on whether the
|
||||
* caller has an auth.uid() (cookie session) or an explicit owner (service
|
||||
* role). Everything else is identical.
|
||||
*/
|
||||
export interface CreateCompanyInput {
|
||||
entityType: EntityType
|
||||
companyName: string
|
||||
/** Raw org number as typed; normalised here, refused when malformed. */
|
||||
orgNumber?: string | null
|
||||
/**
|
||||
* company_settings partial to persist. UI-only and managed fields are
|
||||
* stripped here (id, user_id, company_id, timestamps, first-fiscal-year
|
||||
* helpers), so callers may pass the wizard state as-is.
|
||||
*/
|
||||
settings: Record<string, unknown>
|
||||
fiscalPeriod: { startDate: string; endDate: string; name: string }
|
||||
ticLookup?: CompanyLookupResult | null
|
||||
}
|
||||
|
||||
export type CreateCompanyResult = { companyId: string; error?: undefined } | { companyId?: undefined; error: string }
|
||||
|
||||
export const COMPANY_CREATION_ERRORS = {
|
||||
org_number_invalid: 'org_number_invalid',
|
||||
create_failed: 'Kunde inte skapa företag. Försök igen.',
|
||||
org_number_save_failed: 'Kunde inte spara organisationsnummer. Försök igen.',
|
||||
chart_failed: 'Kunde inte skapa kontoplan. Försök igen.',
|
||||
settings_failed: 'Kunde inte spara inställningar. Försök igen.',
|
||||
period_failed: 'Kunde inte skapa räkenskapsår. Försök igen.',
|
||||
deadlines_failed: 'Kunde inte skapa skattedeadlines. Försök igen.',
|
||||
} as const
|
||||
|
||||
export async function createCompanyCore(
|
||||
supabase: SupabaseClient,
|
||||
input: CreateCompanyInput,
|
||||
createCompanyRow: () => PromiseLike<{ data: unknown; error: unknown }>,
|
||||
): Promise<CreateCompanyResult> {
|
||||
// Org-number format validation. We intentionally do NOT enforce
|
||||
// uniqueness: the same org number may legitimately appear on multiple
|
||||
// companies (a separate test copy of your real company, or a consultant
|
||||
// and the owner each tracking the same entity). Tenant isolation
|
||||
// (RLS + company_id) is the real boundary, not org-number uniqueness.
|
||||
//
|
||||
// normalizeOrgNumber returns null for malformed input: we refuse rather
|
||||
// than storing a value that would break SIE/SRU exports later.
|
||||
const rawOrgNumber = input.orgNumber ?? undefined
|
||||
const cleanedOrgNumber = normalizeOrgNumber(rawOrgNumber)
|
||||
if (rawOrgNumber && rawOrgNumber.trim() && !cleanedOrgNumber) {
|
||||
return { error: COMPANY_CREATION_ERRORS.org_number_invalid }
|
||||
}
|
||||
|
||||
// 1. Create company + owner membership atomically via RPC
|
||||
const { data: newCompanyIdRaw, error: companyError } = await createCompanyRow()
|
||||
const newCompanyId = typeof newCompanyIdRaw === 'string' ? newCompanyIdRaw : null
|
||||
|
||||
if (companyError || !newCompanyId) {
|
||||
console.error('[createCompany] company creation failed', companyError)
|
||||
return { error: COMPANY_CREATION_ERRORS.create_failed }
|
||||
}
|
||||
|
||||
// Helper: roll back the company if a subsequent step fails. Deletes in FK
|
||||
// order. Each delete is error-checked so a failed cleanup leaves a trace
|
||||
// instead of silently stranding partial company data behind a generic
|
||||
// "try again" message.
|
||||
const rollback = async (reason: string, err: unknown) => {
|
||||
console.error(`[createCompany] rolling back ${newCompanyId}: ${reason}`, err)
|
||||
const deletions: Array<[table: string, run: () => PromiseLike<{ error: unknown }>]> = [
|
||||
['company_settings', () => supabase.from('company_settings').delete().eq('company_id', newCompanyId)],
|
||||
['fiscal_periods', () => supabase.from('fiscal_periods').delete().eq('company_id', newCompanyId)],
|
||||
['chart_of_accounts', () => supabase.from('chart_of_accounts').delete().eq('company_id', newCompanyId)],
|
||||
['company_members', () => supabase.from('company_members').delete().eq('company_id', newCompanyId)],
|
||||
['companies', () => supabase.from('companies').delete().eq('id', newCompanyId)],
|
||||
]
|
||||
for (const [table, run] of deletions) {
|
||||
const { error: deleteError } = await run()
|
||||
if (deleteError) {
|
||||
console.error(
|
||||
`[createCompany] rollback delete failed for ${table} (company ${newCompanyId})`,
|
||||
deleteError,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror the normalized org_number onto the companies row so future
|
||||
// duplicate checks and cross-references are reliable. MUST be error-checked
|
||||
// and rolled back on failure: otherwise the freshly-created company would
|
||||
// exist without an org_number and the duplicate guard would never match it
|
||||
// for any future user (the very guard this code is enforcing).
|
||||
if (cleanedOrgNumber) {
|
||||
const { error: orgUpdateError } = await supabase
|
||||
.from('companies')
|
||||
.update({ org_number: cleanedOrgNumber })
|
||||
.eq('id', newCompanyId)
|
||||
if (orgUpdateError) {
|
||||
await rollback('org_number update failed', orgUpdateError)
|
||||
return { error: COMPANY_CREATION_ERRORS.org_number_save_failed }
|
||||
}
|
||||
}
|
||||
|
||||
// Persist whatever lookup data the caller already gathered. Do NOT call
|
||||
// /profile here: that handler fans out to 13 Lens calls and the 5 s
|
||||
// timeout in tic-fetch.ts ate ~530 wasted calls in May before yielding
|
||||
// zero snapshots (every signup's /profile timed out, but the in-flight
|
||||
// upstream fetches still counted against quota). The agent build path
|
||||
// (app/(onboarding)/onboarding/agent/page.tsx) calls ensureTicSnapshot
|
||||
// with upgradeV1: true lazily, which is the right place: only companies
|
||||
// that actually reach agent onboarding spend the budget.
|
||||
if (input.ticLookup) {
|
||||
const { error: ticErr } = await supabase
|
||||
.from('companies')
|
||||
.update({
|
||||
tic_snapshot: input.ticLookup,
|
||||
tic_snapshot_fetched_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', newCompanyId)
|
||||
if (ticErr) {
|
||||
console.warn('[createCompany] tic snapshot persist failed', ticErr)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Seed chart of accounts
|
||||
const { error: coaError } = await supabase.rpc('seed_chart_of_accounts', {
|
||||
p_company_id: newCompanyId,
|
||||
p_entity_type: input.entityType,
|
||||
})
|
||||
if (coaError) {
|
||||
await rollback('COA seeding failed', coaError)
|
||||
return { error: COMPANY_CREATION_ERRORS.chart_failed }
|
||||
}
|
||||
|
||||
// 3. Save settings (strip UI-only and managed fields)
|
||||
const {
|
||||
id: _id,
|
||||
user_id: _uid,
|
||||
company_id: _cid,
|
||||
created_at: _ca,
|
||||
updated_at: _ua,
|
||||
is_first_fiscal_year: _ify,
|
||||
first_year_start: _fys,
|
||||
first_year_end: _fye,
|
||||
...settingsToSave
|
||||
} = input.settings
|
||||
|
||||
// Defence in depth: this upsert bypasses UpdateSettingsSchema, so never persist
|
||||
// a VAT number blind. Normalise to the canonical SE+12 form; if it isn't
|
||||
// structurally valid (e.g. the legacy SE+14 personnummer derivation), re-derive
|
||||
// it from the org number, falling back to null rather than storing a malformed
|
||||
// momsregistreringsnummer.
|
||||
if (typeof settingsToSave.vat_number === 'string' && settingsToSave.vat_number) {
|
||||
const normalized = normalizeVatNumber(settingsToSave.vat_number)
|
||||
settingsToSave.vat_number = isValidSwedishVatNumber(normalized)
|
||||
? normalized
|
||||
: deriveSwedishVatNumber(settingsToSave.org_number as string | null | undefined)
|
||||
}
|
||||
|
||||
const { error: settingsError } = await supabase
|
||||
.from('company_settings')
|
||||
.upsert(
|
||||
{
|
||||
...settingsToSave,
|
||||
company_id: newCompanyId,
|
||||
onboarding_complete: true,
|
||||
onboarding_step: 4,
|
||||
},
|
||||
{ onConflict: 'company_id' },
|
||||
)
|
||||
|
||||
if (settingsError) {
|
||||
await rollback('settings upsert failed', settingsError)
|
||||
return { error: COMPANY_CREATION_ERRORS.settings_failed }
|
||||
}
|
||||
|
||||
// 4. Create fiscal period
|
||||
const { error: periodError } = await supabase.from('fiscal_periods').upsert(
|
||||
{
|
||||
company_id: newCompanyId,
|
||||
name: input.fiscalPeriod.name,
|
||||
period_start: input.fiscalPeriod.startDate,
|
||||
period_end: input.fiscalPeriod.endDate,
|
||||
},
|
||||
{ onConflict: 'company_id,period_start,period_end' },
|
||||
)
|
||||
|
||||
if (periodError) {
|
||||
await rollback('fiscal period upsert failed', periodError)
|
||||
return { error: COMPANY_CREATION_ERRORS.period_failed }
|
||||
}
|
||||
|
||||
// 5. Create the automatic tax deadlines while the onboarding data is still
|
||||
// available. Treat this as part of company creation so a new company never
|
||||
// starts in the broken state where valid settings exist without deadlines.
|
||||
try {
|
||||
await regenerateTaxDeadlinesForUser(
|
||||
supabase,
|
||||
newCompanyId,
|
||||
toDeadlineSettings(settingsToSave as Partial<CompanySettingsForDeadlines>),
|
||||
)
|
||||
} catch (deadlineError) {
|
||||
await rollback('tax deadline generation failed', deadlineError)
|
||||
return { error: COMPANY_CREATION_ERRORS.deadlines_failed }
|
||||
}
|
||||
|
||||
return { companyId: newCompanyId }
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { z } from 'zod'
|
||||
import { saneIsoDateSchema } from '@/lib/invariants/zod'
|
||||
import { computeFiscalPeriod } from '@/lib/company/compute-fiscal-period'
|
||||
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
|
||||
import { deriveSwedishVatNumber } from '@/lib/vat/vat-number'
|
||||
import type { CreateCompanyInput } from '@/lib/company/create-company'
|
||||
|
||||
/**
|
||||
* Typed company-setup input for the agent and API creation paths (the MCP
|
||||
* tool gnubok_create_company and POST /api/v1/companies, issue #1814 PR 3),
|
||||
* turned into the same `settings` + `fiscalPeriod` shape the web wizard
|
||||
* hands to createCompanyCore. One schema, one builder, so the two
|
||||
* programmatic paths cannot drift from each other or from the wizard.
|
||||
*
|
||||
* Compliance rules that must never be skipped: a VAT-registered company
|
||||
* needs a moms_period and an org_number (the invoice momsregistreringsnummer
|
||||
* derives from it), F-skatt is stated explicitly rather than assumed, and an
|
||||
* enskild firma stays on the calendar year even in its first year. Chief
|
||||
* among them, the moms_period: a VAT-registered company
|
||||
* needs a moms_period. Without it the deadline engine silently generates
|
||||
* ZERO VAT deadlines (lib/tax/deadline-config.ts conditions all require a
|
||||
* concrete period), which reads as "no VAT duty" to everyone downstream.
|
||||
* The schema refuses the combination instead of warning.
|
||||
*/
|
||||
export const CompanySetupSchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1).max(200),
|
||||
entity_type: z.enum(['enskild_firma', 'aktiebolag']),
|
||||
org_number: z.string().trim().min(1).max(20).optional(),
|
||||
vat_registered: z.boolean(),
|
||||
moms_period: z.enum(['monthly', 'quarterly', 'yearly']).nullable().optional(),
|
||||
accounting_method: z.enum(['accrual', 'cash']),
|
||||
/** Godkänd för F-skatt. Explicit on purpose: never assumed (SE-R-005 risk). */
|
||||
f_skatt: z.boolean(),
|
||||
/** 1-12. Ignored for enskild firma (always calendar year). */
|
||||
fiscal_year_start_month: z.number().int().min(1).max(12).optional(),
|
||||
/**
|
||||
* First fiscal year of a newly formed company (BFL 3 kap.): may be
|
||||
* shorter or longer than 12 months. Both dates YYYY-MM-DD.
|
||||
*/
|
||||
first_fiscal_year: z
|
||||
.object({
|
||||
start: saneIsoDateSchema,
|
||||
end: saneIsoDateSchema,
|
||||
})
|
||||
.optional(),
|
||||
address_line1: z.string().trim().max(200).optional(),
|
||||
postal_code: z.string().trim().max(20).optional(),
|
||||
city: z.string().trim().max(100).optional(),
|
||||
/** Team (byrå) to attach the company to. Defaults to the caller's own team. */
|
||||
team_id: z.string().uuid().optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.vat_registered && !value.moms_period) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['moms_period'],
|
||||
message:
|
||||
'moms_period is required when vat_registered is true: without it no VAT deadlines are generated (silent misconfiguration).',
|
||||
})
|
||||
}
|
||||
if (!value.vat_registered && value.moms_period) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['moms_period'],
|
||||
message: 'moms_period must be omitted when the company is not VAT-registered.',
|
||||
})
|
||||
}
|
||||
if (value.vat_registered && !value.org_number) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['org_number'],
|
||||
message:
|
||||
'org_number is required for a VAT-registered company: the momsregistreringsnummer on every invoice is derived from it (ML 17 kap 24 §).',
|
||||
})
|
||||
}
|
||||
if (
|
||||
value.entity_type === 'enskild_firma' &&
|
||||
value.first_fiscal_year &&
|
||||
!value.first_fiscal_year.end.endsWith('-12-31')
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['first_fiscal_year', 'end'],
|
||||
message:
|
||||
'An enskild firma always closes its fiscal year on 31 December (BFL 3 kap. 1 §): the first year may be shorter or up to 18 months, but must end on 12-31.',
|
||||
})
|
||||
}
|
||||
if (value.org_number && normalizeOrgNumber(value.org_number) === null) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['org_number'],
|
||||
message: 'org_number is not a valid Swedish organisationsnummer.',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export type CompanySetup = z.infer<typeof CompanySetupSchema>
|
||||
|
||||
export type CompanySetupPlan =
|
||||
| {
|
||||
ok: true
|
||||
input: Omit<CreateCompanyInput, 'ticLookup'>
|
||||
/** What the fiscal period resolved to, for previews. */
|
||||
fiscalPeriod: { startDate: string; endDate: string; name: string }
|
||||
}
|
||||
| { ok: false; error: string }
|
||||
|
||||
/**
|
||||
* Resolve validated setup input into the creation payload. The only failure
|
||||
* left at this point is an invalid fiscal period (validatePeriodDuration).
|
||||
*/
|
||||
export function planCompanySetup(setup: CompanySetup): CompanySetupPlan {
|
||||
const isEf = setup.entity_type === 'enskild_firma'
|
||||
const firstYear = setup.first_fiscal_year
|
||||
const startMonth = isEf ? 1 : (setup.fiscal_year_start_month ?? 1)
|
||||
|
||||
const settings: Record<string, unknown> = {
|
||||
entity_type: setup.entity_type,
|
||||
company_name: setup.name,
|
||||
org_number: setup.org_number ? normalizeOrgNumber(setup.org_number) : null,
|
||||
vat_registered: setup.vat_registered,
|
||||
vat_number: setup.vat_registered ? deriveSwedishVatNumber(setup.org_number ?? null) : null,
|
||||
moms_period: setup.vat_registered ? setup.moms_period ?? null : null,
|
||||
accounting_method: setup.accounting_method,
|
||||
f_skatt: setup.f_skatt,
|
||||
// Enskild firma is calendar-year by law, with or without a first year.
|
||||
fiscal_year_start_month: isEf ? 1 : firstYear ? nextMonthAfter(firstYear.end) : startMonth,
|
||||
...(setup.address_line1 ? { address_line1: setup.address_line1 } : {}),
|
||||
...(setup.postal_code ? { postal_code: setup.postal_code } : {}),
|
||||
...(setup.city ? { city: setup.city } : {}),
|
||||
// Wizard helpers consumed by computeFiscalPeriod and stripped by
|
||||
// createCompanyCore before the settings upsert.
|
||||
is_first_fiscal_year: Boolean(firstYear),
|
||||
first_year_start: firstYear?.start,
|
||||
first_year_end: firstYear?.end,
|
||||
}
|
||||
|
||||
const period = computeFiscalPeriod(settings)
|
||||
if (period.error) {
|
||||
return { ok: false, error: period.error }
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
input: {
|
||||
entityType: setup.entity_type,
|
||||
companyName: setup.name,
|
||||
orgNumber: setup.org_number,
|
||||
settings,
|
||||
fiscalPeriod: { startDate: period.startStr, endDate: period.endStr, name: period.periodName },
|
||||
},
|
||||
fiscalPeriod: { startDate: period.startStr, endDate: period.endStr, name: period.periodName },
|
||||
}
|
||||
}
|
||||
|
||||
/** The fiscal_year_start_month implied by a first fiscal year ending in `end`. */
|
||||
function nextMonthAfter(end: string): number {
|
||||
const endMonth = Number(end.split('-')[1])
|
||||
if (!Number.isInteger(endMonth) || endMonth < 1 || endMonth > 12) return 1
|
||||
return endMonth === 12 ? 1 : endMonth + 1
|
||||
}
|
||||
@@ -22,6 +22,9 @@ const DISPATCH_ONLY_MCP_TOOLS = new Set<string>([
|
||||
'gnubok_upload_document',
|
||||
'gnubok_create_document_upload',
|
||||
'gnubok_complete_document_upload',
|
||||
// Onboarding connect-link tools: read status + hand out a browser link; no commit counterpart.
|
||||
'gnubok_connect_bank',
|
||||
'gnubok_connect_skatteverket',
|
||||
])
|
||||
|
||||
describe('MCP_TOOL_CAPABILITY_MAP', () => {
|
||||
@@ -30,6 +33,8 @@ describe('MCP_TOOL_CAPABILITY_MAP', () => {
|
||||
gnubok_send_invoice: CAPABILITY.email_send,
|
||||
gnubok_vat_declaration_submit: CAPABILITY.skatteverket,
|
||||
gnubok_agi_submit: CAPABILITY.skatteverket,
|
||||
gnubok_connect_bank: CAPABILITY.bank_sync,
|
||||
gnubok_connect_skatteverket: CAPABILITY.skatteverket,
|
||||
// Dispatch-only AI tools: inline Bedrock OCR, no staged operation. The
|
||||
// signed-URL pair is gated at create AND complete so a free-tier key can
|
||||
// neither reserve nor finalize a paid extraction.
|
||||
|
||||
@@ -89,6 +89,9 @@ export const MCP_TOOL_CAPABILITY_MAP: Readonly<Partial<Record<string, Capability
|
||||
gnubok_send_invoice: CAPABILITY.email_send,
|
||||
gnubok_vat_declaration_submit: CAPABILITY.skatteverket,
|
||||
gnubok_agi_submit: CAPABILITY.skatteverket,
|
||||
// Onboarding connect-link tools (issue #1814): gated like the links' targets.
|
||||
gnubok_connect_bank: CAPABILITY.bank_sync,
|
||||
gnubok_connect_skatteverket: CAPABILITY.skatteverket,
|
||||
// AI document OCR (Bedrock): the inbox's paid extraction, reachable via MCP.
|
||||
gnubok_create_document_upload: CAPABILITY.ai,
|
||||
gnubok_complete_document_upload: CAPABILITY.ai,
|
||||
|
||||
@@ -8,7 +8,7 @@ description: >-
|
||||
transactions and reconciliation, payroll (lön), VAT/moms and financial
|
||||
reports, SIE import/export, documents, webhooks. Covers auth with
|
||||
gnubok_sk_ API keys, conventions (dry-run, idempotency, cursor
|
||||
pagination, scopes), and all 135 endpoints.
|
||||
pagination, scopes), and all 136 endpoints.
|
||||
---
|
||||
|
||||
<!-- GENERATED FILE, do not edit. Source: lib/api/v1 registry + scripts/api-skill/overlays. Regenerate with `npm run apiskill:generate`. -->
|
||||
@@ -140,15 +140,16 @@ call can undo it, e.g. invoice credit).
|
||||
|
||||
## Endpoint index
|
||||
|
||||
API version `2026-05-12`, 135 operations. Paths are shown without
|
||||
API version `2026-05-12`, 136 operations. Paths are shown without
|
||||
their `/api/v1` prefix (full base URL: `https://app.gnubok.se/api/v1`).
|
||||
|
||||
### Core (4)
|
||||
### Core (5)
|
||||
|
||||
Full detail: [references/core.md](references/core.md)
|
||||
|
||||
```text
|
||||
GET /companies : List companies the API key can access [scope:companies:read risk:low idempotent]
|
||||
POST /companies : Create a company and set it up for bookkeeping [scope:companies:write risk:medium dry-run]
|
||||
PATCH /companies/{companyId}/settings : Partially update company settings [scope:companies:write risk:medium idempotent dry-run reversible]
|
||||
GET /health : Health check [risk:low idempotent]
|
||||
GET /operations/{id} : Poll a long-running operation by id [scope:operations:read risk:low idempotent]
|
||||
|
||||
@@ -37,6 +37,69 @@ Response `200`:
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/v1/companies`
|
||||
|
||||
**Create a company and set it up for bookkeeping.**
|
||||
`scope:companies:write · risk:medium · dry-run`
|
||||
|
||||
Creates a new company owned by the API key user (or attached to one of their teams) and sets it up in one call: owner membership, BAS chart of accounts for the company form, compliance settings, the first fiscal period and the automatic tax deadlines. A 30-day trial with every paid capability starts immediately. Intended for partner platforms provisioning client companies (byrå/vertical SaaS) and for agents onboarding a user.
|
||||
|
||||
**Use when:** A platform or agent needs to provision a company that does not exist in Accounted yet. The caller becomes its owner; invite the end customer afterwards.
|
||||
**Do not use for:** Companies that already exist (list them with GET /api/v1/companies), or changing settings on an existing company (PATCH /api/v1/companies/{companyId}/settings).
|
||||
|
||||
**Pitfalls:**
|
||||
- A VAT-registered company MUST send moms_period (monthly / quarterly / yearly); the request is refused otherwise, because a missing period silently produces zero VAT deadlines.
|
||||
- Bookkeeping duty under BFL starts when the company exists with a fiscal period: do not create companies to try things out. Use a test-mode key (dry run) for that.
|
||||
- Enskild firma always runs on the calendar year; fiscal_year_start_month is ignored for it.
|
||||
- first_fiscal_year is only for a company in its first year (BFL 3 kap.: up to 18 months). Omit it for an established company.
|
||||
- Not idempotent, and Idempotency-Key is not honoured on this company-less route: a retry after a network failure creates a second company. List GET /api/v1/companies before retrying.
|
||||
- org_number is required for a VAT-registered company (the invoice momsregistreringsnummer derives from it), and f_skatt must be stated explicitly: F-skatt approval is never assumed.
|
||||
|
||||
Request body:
|
||||
```ts
|
||||
{
|
||||
name: string,
|
||||
entity_type: "enskild_firma" | "aktiebolag",
|
||||
org_number?: string,
|
||||
vat_registered: boolean,
|
||||
moms_period?: "monthly" | "quarterly" | "yearly",
|
||||
accounting_method: "accrual" | "cash",
|
||||
f_skatt: boolean,
|
||||
fiscal_year_start_month?: number,
|
||||
first_fiscal_year?: { start: string, end: string },
|
||||
address_line1?: string,
|
||||
postal_code?: string,
|
||||
city?: string,
|
||||
team_id?: string
|
||||
}
|
||||
```
|
||||
|
||||
Response `200`:
|
||||
```ts
|
||||
{
|
||||
data: {
|
||||
id: string,
|
||||
name: string,
|
||||
entity_type: "enskild_firma" | "aktiebolag",
|
||||
org_number: string,
|
||||
vat_registered: boolean,
|
||||
moms_period: "monthly" | "quarterly" | "yearly",
|
||||
accounting_method: "accrual" | "cash",
|
||||
fiscal_period: { start_date: string, end_date: string, name: string },
|
||||
team_id: string
|
||||
},
|
||||
meta: {
|
||||
request_id: string,
|
||||
api_version: string,
|
||||
next_cursor?: string,
|
||||
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
|
||||
partial_expansions?: string[]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `PATCH /api/v1/companies/{companyId}/settings`
|
||||
|
||||
**Partially update company settings.**
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
-- Migration: create_company_for_user (service-role company creation)
|
||||
--
|
||||
-- Agent-first onboarding (issue #1814 PR 3): the MCP tool gnubok_create_company
|
||||
-- and POST /api/v1/companies create companies on behalf of the API key's
|
||||
-- user. Both run with a service-role client, where auth.uid() is NULL, so
|
||||
-- create_company_with_owner (which derives the owner from auth.uid()) cannot
|
||||
-- be used. This variant takes the owner explicitly and is callable by
|
||||
-- service_role ONLY: an authenticated or anonymous caller must never be able
|
||||
-- to create a company for someone else.
|
||||
--
|
||||
-- Body mirrors create_company_with_owner (20260519180000) step for step:
|
||||
-- entity_type whitelist, team-membership authorization for p_team_id, the
|
||||
-- companies + company_members inserts, the 1930 SEK cash account seed, the
|
||||
-- active-company preference, and team sync. The trial capability grant is
|
||||
-- minted by the AFTER INSERT trigger on companies, same as every other path.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.create_company_for_user(
|
||||
p_user_id uuid,
|
||||
p_name text,
|
||||
p_entity_type text,
|
||||
p_team_id uuid DEFAULT NULL
|
||||
)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_company_id uuid;
|
||||
BEGIN
|
||||
IF p_user_id IS NULL THEN
|
||||
RAISE EXCEPTION 'p_user_id is required';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM auth.users WHERE id = p_user_id) THEN
|
||||
RAISE EXCEPTION 'Unknown user %', p_user_id
|
||||
USING ERRCODE = '23503'; -- foreign_key_violation
|
||||
END IF;
|
||||
|
||||
IF p_entity_type NOT IN ('enskild_firma', 'aktiebolag') THEN
|
||||
RAISE EXCEPTION 'Invalid entity_type: %', p_entity_type;
|
||||
END IF;
|
||||
|
||||
IF p_name IS NULL OR length(btrim(p_name)) = 0 THEN
|
||||
RAISE EXCEPTION 'p_name is required';
|
||||
END IF;
|
||||
|
||||
-- Same authorization as create_company_with_owner, against the explicit
|
||||
-- owner: SECURITY DEFINER bypasses RLS, so team membership is checked here.
|
||||
IF p_team_id IS NOT NULL THEN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.team_members
|
||||
WHERE team_id = p_team_id
|
||||
AND user_id = p_user_id
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Not a member of team %', p_team_id
|
||||
USING ERRCODE = '42501'; -- insufficient_privilege
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.companies (name, entity_type, created_by, team_id)
|
||||
VALUES (btrim(p_name), p_entity_type, p_user_id, p_team_id)
|
||||
RETURNING id INTO v_company_id;
|
||||
|
||||
INSERT INTO public.company_members (company_id, user_id, role)
|
||||
VALUES (v_company_id, p_user_id, 'owner');
|
||||
|
||||
INSERT INTO public.cash_accounts (
|
||||
company_id, ledger_account, currency, name, enabled, is_primary, source
|
||||
)
|
||||
VALUES (
|
||||
v_company_id, '1930', 'SEK', 'Företagskonto (SEK)', true, true, 'manual'
|
||||
)
|
||||
ON CONFLICT (company_id, ledger_account) DO NOTHING;
|
||||
|
||||
INSERT INTO public.user_preferences (user_id, active_company_id)
|
||||
VALUES (p_user_id, v_company_id)
|
||||
ON CONFLICT (user_id)
|
||||
DO UPDATE SET active_company_id = EXCLUDED.active_company_id;
|
||||
|
||||
IF p_team_id IS NOT NULL THEN
|
||||
PERFORM public.sync_team_to_company(v_company_id, p_team_id);
|
||||
END IF;
|
||||
|
||||
RETURN v_company_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Service role only. PostgREST exposes functions to every role by default
|
||||
-- (PUBLIC grant), so revoke first, then grant the one role that may call it.
|
||||
REVOKE ALL ON FUNCTION public.create_company_for_user(uuid, text, text, uuid) FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION public.create_company_for_user(uuid, text, text, uuid) FROM anon;
|
||||
REVOKE ALL ON FUNCTION public.create_company_for_user(uuid, text, text, uuid) FROM authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.create_company_for_user(uuid, text, text, uuid) TO service_role;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PoolClient } from 'pg'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { getClient, getPool, runAsServiceRole } from './setup'
|
||||
import { insertAuthUser } from './fixtures'
|
||||
|
||||
/**
|
||||
* create_company_for_user (migration 20260825120000): the service-role twin
|
||||
* of create_company_with_owner used by the MCP tool gnubok_create_company and
|
||||
* POST /api/v1/companies (issue #1814 PR 3).
|
||||
*
|
||||
* Locks in:
|
||||
* - service role creates the company, owner membership, 1930 cash account
|
||||
* and active-company preference for the explicit owner, and the trial
|
||||
* grant trigger fires for it like for every other creation path
|
||||
* - authenticated and anon callers are refused outright (42501): the
|
||||
* function takes the owner as a plain argument, so exposing it to
|
||||
* PostgREST roles would let anyone create companies for anyone
|
||||
* - an unknown owner is refused (23503) and a foreign team is refused (42501)
|
||||
*/
|
||||
|
||||
async function asRole<T>(
|
||||
role: 'authenticated' | 'anon',
|
||||
userId: string | null,
|
||||
fn: (client: PoolClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const client = await getClient()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
await client.query(`SELECT set_config('request.jwt.claims', $1, true)`, [
|
||||
JSON.stringify(userId ? { sub: userId, role } : { role }),
|
||||
])
|
||||
await client.query(`SELECT set_config('request.jwt.claim.sub', $1, true)`, [userId ?? ''])
|
||||
await client.query(`SELECT set_config('request.jwt.claim.role', $1, true)`, [role])
|
||||
await client.query(`SET LOCAL ROLE ${role}`)
|
||||
const result = await fn(client)
|
||||
await client.query('COMMIT')
|
||||
return result
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK').catch(() => {})
|
||||
throw error
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
|
||||
const CREATE = `SELECT public.create_company_for_user($1::uuid, $2, $3, $4::uuid) AS id`
|
||||
|
||||
describe('create_company_for_user.pg', () => {
|
||||
it('creates company, owner membership, cash account and preference for the explicit owner', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
const name = `Provisioned AB ${randomUUID().slice(0, 8)}`
|
||||
|
||||
const created = await getPool().query<{ id: string }>(CREATE, [userId, name, 'aktiebolag', null])
|
||||
const companyId = created.rows[0]!.id
|
||||
expect(companyId).toMatch(/^[0-9a-f-]{36}$/)
|
||||
|
||||
const company = await getPool().query(
|
||||
`SELECT name, entity_type, created_by, team_id FROM public.companies WHERE id = $1`,
|
||||
[companyId],
|
||||
)
|
||||
expect(company.rows[0]).toMatchObject({ name, entity_type: 'aktiebolag', created_by: userId, team_id: null })
|
||||
|
||||
const member = await getPool().query(
|
||||
`SELECT role FROM public.company_members WHERE company_id = $1 AND user_id = $2`,
|
||||
[companyId, userId],
|
||||
)
|
||||
expect(member.rows[0]).toMatchObject({ role: 'owner' })
|
||||
|
||||
const cash = await getPool().query(
|
||||
`SELECT ledger_account, is_primary FROM public.cash_accounts WHERE company_id = $1`,
|
||||
[companyId],
|
||||
)
|
||||
expect(cash.rows).toEqual([{ ledger_account: '1930', is_primary: true }])
|
||||
|
||||
const prefs = await getPool().query(
|
||||
`SELECT active_company_id FROM public.user_preferences WHERE user_id = $1`,
|
||||
[userId],
|
||||
)
|
||||
expect(prefs.rows[0]).toMatchObject({ active_company_id: companyId })
|
||||
|
||||
// The trial trigger on companies covers this path like every other one.
|
||||
const grants = await getPool().query(
|
||||
`SELECT count(*)::int AS n FROM public.capability_grants WHERE company_id = $1 AND source = 'trial'`,
|
||||
[companyId],
|
||||
)
|
||||
expect(grants.rows[0]!.n).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('runs the whole creation core under the real service_role, including the BAS chart seed', async () => {
|
||||
// The MCP tool and POST /api/v1/companies run createCompanyCore with a
|
||||
// service-role client. seed_chart_of_accounts is SECURITY DEFINER with a
|
||||
// grant to `authenticated` only; this pins that service_role (PUBLIC
|
||||
// execute, no REVOKE) can still call it, which unit tests cannot see.
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await runAsServiceRole(async (client) => {
|
||||
const created = await client.query<{ id: string }>(CREATE, [userId, 'Service AB', 'aktiebolag', null])
|
||||
const id = created.rows[0]!.id
|
||||
await client.query(`SELECT public.seed_chart_of_accounts($1::uuid, 'aktiebolag')`, [id])
|
||||
return id
|
||||
})
|
||||
const chart = await getPool().query<{ n: number }>(
|
||||
`SELECT count(*)::int AS n FROM public.chart_of_accounts WHERE company_id = $1`,
|
||||
[companyId],
|
||||
)
|
||||
// The starter chart is a curated subset (41 accounts on CI), not the full BAS list.
|
||||
expect(chart.rows[0]!.n).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('refuses an authenticated caller even for their own user id', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
await expect(
|
||||
asRole('authenticated', userId, async (client) => {
|
||||
await client.query(CREATE, [userId, 'Sneaky AB', 'aktiebolag', null])
|
||||
}),
|
||||
).rejects.toMatchObject({ code: '42501' })
|
||||
})
|
||||
|
||||
it('refuses an anon caller', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
await expect(
|
||||
asRole('anon', null, async (client) => {
|
||||
await client.query(CREATE, [userId, 'Sneaky AB', 'aktiebolag', null])
|
||||
}),
|
||||
).rejects.toMatchObject({ code: '42501' })
|
||||
})
|
||||
|
||||
it('refuses an unknown owner', async () => {
|
||||
await expect(
|
||||
getPool().query(CREATE, [randomUUID(), 'Ghost AB', 'aktiebolag', null]),
|
||||
).rejects.toMatchObject({ code: '23503' })
|
||||
})
|
||||
|
||||
it('refuses a team the owner is not a member of', async () => {
|
||||
const owner = await insertAuthUser()
|
||||
const other = await insertAuthUser()
|
||||
const teamId = randomUUID()
|
||||
await getPool().query(`INSERT INTO public.teams (id, name, created_by) VALUES ($1, 'Other', $2)`, [teamId, other])
|
||||
await getPool().query(
|
||||
`INSERT INTO public.team_members (team_id, user_id, role) VALUES ($1, $2, 'owner')`,
|
||||
[teamId, other],
|
||||
)
|
||||
await expect(
|
||||
getPool().query(CREATE, [owner, 'Wrong Team AB', 'aktiebolag', teamId]),
|
||||
).rejects.toMatchObject({ code: '42501' })
|
||||
})
|
||||
|
||||
it('rejects an unsupported entity type and an empty name', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
await expect(getPool().query(CREATE, [userId, 'X HB', 'handelsbolag', null])).rejects.toThrow(/Invalid entity_type/)
|
||||
await expect(getPool().query(CREATE, [userId, ' ', 'aktiebolag', null])).rejects.toThrow(/p_name is required/)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user