* feat(onboarding): minimal input: orgnr + moms period is the whole ask
Two fixes from the third E2E attempt (2026-08-26):
1. accounting_method is now optional in CompanySetupSchema and defaults by
form in planCompanySetup: aktiebolag = accrual (the norm), enskild
firma = cash (the common small-EF choice; legal under 3 MSEK, BFL 4
kap 4 paragraf). The plan flags the default (resolved.accountingMethodDefaulted)
and gnubok_create_company's preview carries accounting_method_defaulted
so the readback names it and the user overrides in the same 'ja'.
Never silent: the preview is the checkpoint. Applies to the MCP tool
and POST /api/v1/companies (additive; response shows the resolved
value). The lookup tool's still_to_ask no longer lists it.
2. The agent refused a real orgnr because the user said 'nytt bolag' and
the registry showed an established company ('Stopp. Numret matchar
inte ett nytt bolag'): lookup instructions now state that an
established company with F-skatt/VAT is the NORMAL case (new = new to
Accounted) and the orgnr is never second-guessed for looking
established.
Skill + plugin (v1.2.1) updated; API skill regenerated; DECISIONS.md entry.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(onboarding): surface the kontantmetod 3-MSEK condition on the defaulted cash method
Compliance-review finding on #1952: the EF cash default carries a legal
eligibility condition (turnover normally under 3 MSEK, BFL 4 kap 4 §)
that a client not reading the onboarding skill would never see. The
create preview now carries accounting_method_note with the condition
whenever cash was defaulted, and the v1 pitfall states it for API
integrators. The registry cannot verify turnover, so the confirm-time
human check is the gate; the default itself stays (a brand-new EF has
zero turnover by definition).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
196 lines
8.0 KiB
TypeScript
196 lines
8.0 KiB
TypeScript
/**
|
|
* gnubok_lookup_company: the org-number-first onboarding entry point. Tests
|
|
* the fact-vs-question split mirrored from lib/onboarding-journey/reducer.ts:
|
|
* registry facts are presented for confirmation, VAT is a fact only when
|
|
* positively registered, moms period and accounting method are always asked.
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
|
|
import { TICAPIError } from '@/extensions/general/tic/lib/tic-types'
|
|
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
lookupCompanyByOrgNumber: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@/extensions/general/tic/lib/lookup', () => ({
|
|
lookupCompanyByOrgNumber: (...args: unknown[]) => mocks.lookupCompanyByOrgNumber(...args),
|
|
}))
|
|
|
|
import { tools } from '../server'
|
|
import { isCompanyDependentTool } from '../company-routing'
|
|
|
|
const tool = tools.find((t) => t.name === 'gnubok_lookup_company')!
|
|
|
|
function found(overrides: Partial<CompanyLookupResult> = {}): CompanyLookupResult {
|
|
return {
|
|
companyName: 'Testbolaget AB',
|
|
isCeased: false,
|
|
address: { street: 'Storgatan 1', postalCode: '111 22', city: 'Stockholm' },
|
|
registration: { fTax: true, vat: true },
|
|
bankAccounts: [],
|
|
email: null,
|
|
phone: null,
|
|
sniCodes: [{ code: '62010', name: 'Dataprogrammering' }],
|
|
fiscalYear: { startMonthDay: '01-01', endMonthDay: '12-31' },
|
|
legalEntityType: 'AB',
|
|
registrationDate: Date.UTC(2018, 2, 1),
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
async function run(orgNumber: string) {
|
|
return (await tool.execute({ org_number: orgNumber }, '', 'user-1', {} as never)) as Record<
|
|
string,
|
|
unknown
|
|
>
|
|
}
|
|
|
|
describe('gnubok_lookup_company', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('is a companies:read, company-independent read tool', () => {
|
|
expect(tool).toBeDefined()
|
|
expect(TOOL_SCOPE_MAP.gnubok_lookup_company).toBe('companies:read')
|
|
expect(isCompanyDependentTool('gnubok_lookup_company')).toBe(false)
|
|
expect(tool.annotations.readOnlyHint).toBe(true)
|
|
})
|
|
|
|
it('rejects a malformed organisationsnummer without spending a registry call', async () => {
|
|
await expect(run('12345')).rejects.toMatchObject({ code: 'VALIDATION_ERROR' })
|
|
expect(mocks.lookupCompanyByOrgNumber).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('treats a VAT-registered AB as facts: prefill everything, ask only period and method', async () => {
|
|
mocks.lookupCompanyByOrgNumber.mockResolvedValue(found())
|
|
const result = await run('556000-0001')
|
|
|
|
expect(result.status).toBe('found')
|
|
expect(result.warnings).toEqual([])
|
|
expect(result.suggested_create_company_input).toMatchObject({
|
|
name: 'Testbolaget AB',
|
|
entity_type: 'aktiebolag',
|
|
org_number: '5560000001',
|
|
f_skatt: true,
|
|
vat_registered: true,
|
|
address_line1: 'Storgatan 1',
|
|
postal_code: '111 22',
|
|
city: 'Stockholm',
|
|
fiscal_year_start_month: 1,
|
|
})
|
|
|
|
const ask = result.still_to_ask as string[]
|
|
expect(ask.some((q) => q.startsWith('moms_period ('))).toBe(true)
|
|
// accounting_method defaults by form in create_company (flagged in its
|
|
// preview) and is deliberately not a question here.
|
|
expect(ask.some((q) => q.startsWith('accounting_method'))).toBe(false)
|
|
// Registry facts are confirmed, never re-asked.
|
|
expect(ask.some((q) => q.startsWith('entity_type'))).toBe(false)
|
|
expect(ask.some((q) => q.startsWith('vat_registered'))).toBe(false)
|
|
expect(ask.some((q) => q.startsWith('name'))).toBe(false)
|
|
// The known fiscal year becomes a confirmation, not an open question.
|
|
expect(ask.some((q) => q.includes('stämmer detta?'))).toBe(true)
|
|
})
|
|
|
|
it('never silently defaults VAT: absence of registration is a question, not a fact', async () => {
|
|
mocks.lookupCompanyByOrgNumber.mockResolvedValue(
|
|
found({ registration: { fTax: false, vat: false } })
|
|
)
|
|
const result = await run('5560000001')
|
|
|
|
const suggested = result.suggested_create_company_input as Record<string, unknown>
|
|
expect('vat_registered' in suggested).toBe(false)
|
|
// f_skatt=false IS a fact (the registry answered), unlike vat=false.
|
|
expect(suggested.f_skatt).toBe(false)
|
|
|
|
const ask = result.still_to_ask as string[]
|
|
expect(ask.some((q) => q.startsWith('vat_registered'))).toBe(true)
|
|
})
|
|
|
|
it('lets the user pick the enskild firma verksamhetsnamn instead of assuming the registered name', async () => {
|
|
mocks.lookupCompanyByOrgNumber.mockResolvedValue(
|
|
found({ legalEntityType: 'EF', companyName: 'Anna Andersson' })
|
|
)
|
|
const result = await run('5560000001')
|
|
|
|
const ask = result.still_to_ask as string[]
|
|
expect(ask.some((q) => q.startsWith('name'))).toBe(true)
|
|
// The registered name still arrives as the suggestion.
|
|
expect((result.suggested_create_company_input as Record<string, unknown>).name).toBe(
|
|
'Anna Andersson'
|
|
)
|
|
})
|
|
|
|
it('flags an unsupported legal form and asks for the entity type', async () => {
|
|
mocks.lookupCompanyByOrgNumber.mockResolvedValue(found({ legalEntityType: 'HB' }))
|
|
const result = await run('5560000001')
|
|
|
|
expect((result.warnings as string[]).some((w) => w.includes('not supported'))).toBe(true)
|
|
const suggested = result.suggested_create_company_input as Record<string, unknown>
|
|
expect('entity_type' in suggested && suggested.entity_type !== undefined).toBe(false)
|
|
expect((result.still_to_ask as string[]).some((q) => q.startsWith('entity_type'))).toBe(true)
|
|
})
|
|
|
|
it('warns about a ceased company but lets the flow continue', async () => {
|
|
mocks.lookupCompanyByOrgNumber.mockResolvedValue(found({ isCeased: true }))
|
|
const result = await run('5560000001')
|
|
|
|
expect(result.status).toBe('found')
|
|
expect((result.warnings as string[]).some((w) => w.includes('CEASED'))).toBe(true)
|
|
})
|
|
|
|
it('suggests a first fiscal year for a recently registered company with no closed period', async () => {
|
|
mocks.lookupCompanyByOrgNumber.mockResolvedValue(
|
|
found({ fiscalYear: null, registrationDate: Date.now() - 60 * 24 * 60 * 60 * 1000 })
|
|
)
|
|
const result = await run('5560000001')
|
|
|
|
const ask = result.still_to_ask as string[]
|
|
expect(ask.some((q) => q.includes('first fiscal year') || q.includes('first_fiscal_year'))).toBe(
|
|
true
|
|
)
|
|
const suggested = result.suggested_create_company_input as Record<string, unknown>
|
|
expect('fiscal_year_start_month' in suggested).toBe(false)
|
|
})
|
|
|
|
it('treats no-closed-period as first year up to 18 months after registration (extended first year)', async () => {
|
|
// The Arcim case: registered 13 months ago, no annual report filed, first
|
|
// räkenskapsår runs to 31 Dec under the BFL 3 kap 3 § 18-month cap.
|
|
mocks.lookupCompanyByOrgNumber.mockResolvedValue(
|
|
found({ fiscalYear: null, registrationDate: Date.now() - 396 * 24 * 60 * 60 * 1000 })
|
|
)
|
|
const result = await run('5560000001')
|
|
|
|
const ask = result.still_to_ask as string[]
|
|
expect(ask.some((q) => q.includes('first_fiscal_year'))).toBe(true)
|
|
expect(ask.some((q) => q.includes('calendar year or broken year'))).toBe(false)
|
|
})
|
|
|
|
it('returns not_found with the full question list when no company matches', async () => {
|
|
mocks.lookupCompanyByOrgNumber.mockResolvedValue(null)
|
|
const result = await run('5560000001')
|
|
|
|
expect(result.status).toBe('not_found')
|
|
expect(result.suggested_create_company_input).toBeNull()
|
|
expect((result.still_to_ask as string[]).length).toBeGreaterThanOrEqual(6)
|
|
})
|
|
|
|
it('degrades to unavailable on a TIC error instead of failing the onboarding', async () => {
|
|
mocks.lookupCompanyByOrgNumber.mockRejectedValue(
|
|
new TICAPIError('not configured', undefined, 'NOT_CONFIGURED')
|
|
)
|
|
const result = await run('5560000001')
|
|
|
|
expect(result.status).toBe('unavailable')
|
|
expect((result.warnings as string[])[0]).toContain('NOT_CONFIGURED')
|
|
expect((result.still_to_ask as string[]).length).toBeGreaterThanOrEqual(6)
|
|
})
|
|
|
|
it('rethrows non-TIC errors', async () => {
|
|
mocks.lookupCompanyByOrgNumber.mockRejectedValue(new Error('boom'))
|
|
await expect(run('5560000001')).rejects.toThrow('boom')
|
|
})
|
|
})
|