Files
accounted/lib/company/actions.ts
T
31e0cd6e05 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>
2026-08-25 12:41:02 +02:00

135 lines
5.0 KiB
TypeScript

'use server'
import { createClient } from '@/lib/supabase/server'
import { setActiveCompany, CompanyContextError } from '@/lib/company/context'
import { revalidatePath } from 'next/cache'
import { createCompanyCore } from '@/lib/company/create-company'
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
import { getErrorMessage } from '@/lib/errors/get-error-message'
/**
* Switch the active company. Returns an error *code* (translated by the
* caller, same pattern as `org_number_invalid` below): 'not_member' when the
* user lacks membership, 'persist_failed' when the user_preferences write
* failed or could not be verified (#701).
*/
export async function switchCompany(companyId: string): Promise<{ error?: string }> {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return { error: 'Unauthorized' }
}
try {
await setActiveCompany(supabase, user.id, companyId)
// No revalidatePath: the client performs a hard navigation
// (window.location.assign) after this action returns, which wipes
// every React/router/fetch cache wholesale. revalidatePath would be a
// no-op and would just race with the hard reload.
return {}
} catch (err) {
console.error('[switchCompany] failed', err)
if (err instanceof CompanyContextError && err.code === 'not_member') {
return { error: 'not_member' }
}
// persist_failed and anything unexpected: a retryable failure, not a
// permissions problem: don't tell the user they lack access.
return { error: 'persist_failed' }
}
}
/**
* Create a company from onboarding wizard data.
*
* This runs on the server so that if the Next.js server is unavailable when
* the user clicks the final "Fortsätt" button, the action never reaches
* Supabase and no ghost company is created. All operations (company,
* membership, chart of accounts, settings, fiscal period, active company)
* happen sequentially; if any step after company creation fails the company
* is rolled back to avoid partial state.
*/
export async function createCompanyFromOnboarding(params: {
teamId: string
settings: Record<string, unknown>
fiscalPeriod: {
startDate: string
endDate: string
name: string
}
// Optional TIC lookup result captured during the onboarding form. When
// supplied, persisted to companies.tic_snapshot so downstream features
// (specialized accountant agent composer, MCP briefing) can read the same
// Bolagsverket-sourced data the form used. Empty for manual entry paths.
ticLookup?: CompanyLookupResult | null
}): Promise<{ companyId?: string; error?: string }> {
try {
return await createCompanyFromOnboardingImpl(params)
} catch (err) {
// Defensive top-level catch: a thrown error escapes to the client as
// an opaque Next.js server-action exception with no message in dev
// and a redacted message in prod. Logging the full error here gives
// us a server-side trace and returns a localized fallback to the UI.
console.error('[createCompanyFromOnboarding] unexpected error', err)
return { error: getErrorMessage(err, { context: 'settings' }) }
}
}
async function createCompanyFromOnboardingImpl(params: {
teamId: string
settings: Record<string, unknown>
fiscalPeriod: { startDate: string; endDate: string; name: string }
ticLookup?: CompanyLookupResult | null
}): Promise<{ companyId?: string; error?: string }> {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return { error: 'Unauthorized' }
}
const entityType = params.settings.entity_type as string | undefined
if (entityType !== 'enskild_firma' && entityType !== 'aktiebolag') {
return { error: 'Ogiltig företagsform.' }
}
const companyName = (params.settings.company_name as string | undefined) || 'Mitt företag'
// 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,
{
entityType,
companyName,
orgNumber: params.settings.org_number as string | undefined,
settings: params.settings,
fiscalPeriod: params.fiscalPeriod,
ticLookup: params.ticLookup,
},
() =>
supabase.rpc('create_company_with_owner', {
p_name: companyName,
p_entity_type: entityType,
p_team_id: params.teamId,
}),
)
if (created.error !== undefined) {
return { error: created.error }
}
const newCompanyId = created.companyId
// 6. Set as active company
try {
await setActiveCompany(supabase, user.id, newCompanyId)
} catch (err) {
// Non-fatal: the company was created successfully; the user can switch manually
console.error('[createCompanyFromOnboarding] setActiveCompany failed', err)
}
revalidatePath('/')
return { companyId: newCompanyId }
}