feat(onboarding): minimal input: orgnr + moms period is the whole ask (#1952)
* 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>
This commit is contained in:
@@ -1263,3 +1263,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-26] gnubok_connect_bank / gnubok_connect_skatteverket moved from catalogVisibility 'search' to the default catalog: Claude.ai can only invoke tools present in tools/list, so search-only tools are discover-only there and the onboarding skill's steps 3-4 dead-ended on client-side tool-not-found (verified via event_log: the server never received the calls). Search-only visibility remains fine for tools an agent reads about before asking the user, but anything a skill instructs the agent to CALL must be in the default catalog.
|
||||
[2026-08-26] Dashboard layout diet (responsiveness plan B4): the four nav-visibility probes (WooCommerce/Shopify connections, webshop orders, mileage trips) became one SECURITY INVOKER RPC get_dashboard_nav_flags (20260826120000) rather than a client-side badge hook, because the flags gate whole nav rows and loading them after mount pops rows into the sidebar; RLS still applies inside the function. The layout's getDashboardAuthContext now verifies the JWT locally (lib/auth/claims.ts, shared with requireAuth) with getUser() as the fallback: the proxy already did the per-request revocation check, so the layout's own getUser() was a second Supabase Auth round trip on every hard load. The user-keyed reads (profile, preferences, memberships) moved into the company-resolution wave and the memberships join now supplies the active company row and role, so the layout went from ~20 network calls in 4 waves to ~12 in 2. The memberships-with-embedded-settings single query was NOT attempted: the PostgREST embed shape was unverified and the settings-names query is parallel anyway.
|
||||
[2026-08-26] OAuth consent pre-checks ALL scopes (one-click, list collapsed in details): founder call after the read-only default dead-ended agent flows; defensible because every write is staged for approval, rows stay untickable, grant revocable.
|
||||
[2026-08-26] accounting_method optional with form default (AB=accrual, EF=cash) in CompanySetupSchema/planCompanySetup: founder call to cut agent onboarding input to orgnr + moms period; the default is flagged (accounting_method_defaulted) and must be read back in the preview, never silent.
|
||||
|
||||
@@ -106,6 +106,7 @@ registerEndpoint({
|
||||
'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.',
|
||||
'accounting_method may be omitted: it then defaults by form (aktiebolag accrual, enskild firma cash) and the response shows the resolved value. The cash default is only legal when turnover normally stays under 3 MSEK (BFL 4 kap 4 §): send accrual explicitly for a larger enskild firma.',
|
||||
],
|
||||
example: {
|
||||
request: {
|
||||
@@ -190,7 +191,7 @@ export const POST = withApiV1('companies.create', async (request, ctx) => {
|
||||
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,
|
||||
accounting_method: plan.resolved.accountingMethod,
|
||||
fiscal_period: {
|
||||
start_date: plan.fiscalPeriod.startDate,
|
||||
end_date: plan.fiscalPeriod.endDate,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "accounted",
|
||||
"displayName": "Accounted",
|
||||
"description": "Official Accounted plugin: Swedish double-entry bookkeeping flows for Claude. Connects your ledger over MCP and ships short workflow skills (daily bookkeeping, health check, month close, VAT, payroll, year-end) that work from the company's live data and load Swedish accounting knowledge from the product when needed. Every write is staged for your approval; nothing is booked on its own.",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"author": {
|
||||
"name": "Accounted (erp-mafia)"
|
||||
},
|
||||
|
||||
@@ -14,7 +14,7 @@ Call `accounted_get_agent_briefing`.
|
||||
|
||||
## Step 2: set up the company
|
||||
|
||||
Call `accounted_load_skill("onboarding")` and follow it. In short: ask for the **organisationsnummer** first and call `accounted_lookup_company`; the public registry answers most of the form (name, address, F-skatt, VAT status, legal form, fiscal year), so present those as facts to confirm and ask only what `still_to_ask` lists (typically the moms period and the accounting method). Then call `accounted_create_company` **without** `confirm` to get a preview, read the preview back in plain Swedish, and only after an explicit "ja" call it again with `confirm: true`.
|
||||
Call `accounted_load_skill("onboarding")` and follow it. In short: ask for the **organisationsnummer** first and call `accounted_lookup_company`; the public registry answers most of the form (name, address, F-skatt, VAT status, legal form, fiscal year), so present those as facts to confirm and ask only what `still_to_ask` lists (typically only the moms period; the accounting method defaults by company form and is confirmed in the preview). Then call `accounted_create_company` **without** `confirm` to get a preview, read the preview back in plain Swedish, and only after an explicit "ja" call it again with `confirm: true`.
|
||||
|
||||
Rules the tool enforces, so do not argue with them: a VAT-registered company needs both an organisationsnummer and a moms period; F-skatt must be stated, never assumed; an enskild firma always runs on the calendar year.
|
||||
|
||||
|
||||
@@ -55,6 +55,25 @@ describe('gnubok_create_company', () => {
|
||||
expect(tool.annotations.destructiveHint).toBe(false)
|
||||
})
|
||||
|
||||
it('defaults an omitted accounting_method by form and flags it in the preview', async () => {
|
||||
const supabase = supabaseWithTeam(TEAM_ID)
|
||||
const { accounting_method: _dropped, ...withoutMethod } = setup
|
||||
const result = (await tool.execute(withoutMethod, '', 'user-1', supabase as never)) as Record<string, unknown>
|
||||
|
||||
expect(result.created).toBe(false)
|
||||
const preview = result.preview as Record<string, unknown>
|
||||
expect(preview.accounting_method).toBe('accrual')
|
||||
expect(preview.accounting_method_defaulted).toBe(true)
|
||||
})
|
||||
|
||||
it('does not flag an explicitly chosen accounting_method as defaulted', async () => {
|
||||
const supabase = supabaseWithTeam(TEAM_ID)
|
||||
const result = (await tool.execute(setup, '', 'user-1', supabase as never)) as Record<string, unknown>
|
||||
const preview = result.preview as Record<string, unknown>
|
||||
expect(preview.accounting_method).toBe('accrual')
|
||||
expect('accounting_method_defaulted' in preview).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>
|
||||
|
||||
@@ -83,7 +83,9 @@ describe('gnubok_lookup_company', () => {
|
||||
|
||||
const ask = result.still_to_ask as string[]
|
||||
expect(ask.some((q) => q.startsWith('moms_period ('))).toBe(true)
|
||||
expect(ask.some((q) => q.startsWith('accounting_method'))).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)
|
||||
|
||||
@@ -3126,7 +3126,9 @@ export const tools: McpTool[] = [
|
||||
if (!vatIsFact) stillToAsk.push('vat_registered (the registry shows no VAT registration; confirm with the user)')
|
||||
if (vatIsFact) stillToAsk.push('moms_period (monthly, quarterly or yearly; never guess)')
|
||||
else stillToAsk.push('moms_period IF vat_registered turns out true')
|
||||
stillToAsk.push('accounting_method (accrual = faktureringsmetoden, cash = kontantmetoden; never guess)')
|
||||
// accounting_method is deliberately NOT in this list: gnubok_create_company
|
||||
// defaults it by form (AB accrual, EF cash) and flags the default in the
|
||||
// preview, where the user confirms or overrides it in the same "ja".
|
||||
|
||||
const startMonth = parseStartMonthDay(lookup.fiscalYear?.startMonthDay)
|
||||
// No closed fiscal period in the registry = no annual report filed yet
|
||||
@@ -3182,7 +3184,7 @@ export const tools: McpTool[] = [
|
||||
still_to_ask: stillToAsk,
|
||||
warnings,
|
||||
instructions:
|
||||
'Present the company facts as a short summary for the user to CONFIRM (name, address, F-skatt, VAT status; do not re-ask them). Then ask ONLY the still_to_ask questions, merge the answers into suggested_create_company_input, and call gnubok_create_company (preview first, read it back, then confirm=true).',
|
||||
'Present the company facts as a short summary for the user to CONFIRM (name, address, F-skatt, VAT status; do not re-ask them). An established company with F-skatt/VAT is the NORMAL case even when the user says "nytt bolag" (new = new to Accounted): never refuse or second-guess the orgnr because the company looks established. Ask ONLY the still_to_ask questions, merge answers into suggested_create_company_input, and call gnubok_create_company (preview, read back incl. the defaulted accounting method, then confirm=true).',
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -3191,7 +3193,7 @@ 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). Call gnubok_lookup_company FIRST to prefill facts from the orgnr. Preview (no confirm), read it back, then confirm=true. Skill: onboarding.',
|
||||
'Create a NEW company, set up for bookkeeping (chart, settings, first fiscal period, tax deadlines; 30-day trial). Ask ONLY orgnr + moms period: gnubok_lookup_company prefills the rest, accounting_method defaults by form. Preview (no confirm), read back, then confirm=true.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -3201,7 +3203,7 @@ export const tools: McpTool[] = [
|
||||
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'] },
|
||||
accounting_method: { type: 'string', enum: ['accrual', 'cash'], description: 'Omit to default by form: aktiebolag accrual, enskild firma cash; the preview flags the default' },
|
||||
f_skatt: { type: 'boolean' },
|
||||
fiscal_year_start_month: { type: 'integer', minimum: 1, maximum: 12 },
|
||||
first_fiscal_year: {
|
||||
@@ -3217,7 +3219,7 @@ export const tools: McpTool[] = [
|
||||
team_id: { type: 'string', format: 'uuid' },
|
||||
confirm: { type: 'boolean', description: 'true creates; omitted = preview' },
|
||||
},
|
||||
required: ['name', 'entity_type', 'vat_registered', 'accounting_method', 'f_skatt'],
|
||||
required: ['name', 'entity_type', 'vat_registered', 'f_skatt'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
@@ -3258,7 +3260,18 @@ export const tools: McpTool[] = [
|
||||
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,
|
||||
accounting_method: plan.resolved.accountingMethod,
|
||||
// Present, never silent: the readback must name the defaulted method
|
||||
// so the user can override it before confirm. The cash default also
|
||||
// carries its eligibility condition (BFL 4 kap 4 §): the registry
|
||||
// cannot verify turnover, so the human must.
|
||||
...(plan.resolved.accountingMethodDefaulted ? { accounting_method_defaulted: true } : {}),
|
||||
...(plan.resolved.accountingMethodDefaulted && plan.resolved.accountingMethod === 'cash'
|
||||
? {
|
||||
accounting_method_note:
|
||||
'Kontantmetoden förutsätter en omsättning som normalt understiger 3 MSEK (BFL 4 kap 4 §); annars gäller faktureringsmetoden. Bekräfta detta med användaren.',
|
||||
}
|
||||
: {}),
|
||||
f_skatt: parsed.data.f_skatt,
|
||||
fiscal_period: plan.fiscalPeriod,
|
||||
team_id: teamId,
|
||||
@@ -3270,7 +3283,7 @@ export const tools: McpTool[] = [
|
||||
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.',
|
||||
'Nothing was created. Read the preview back to the user (especially the fiscal period dates, the VAT setup, and the accounting method when accounting_method_defaulted is true: name the default and let them override), then call gnubok_create_company again with the same arguments and confirm=true.',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,9 +54,14 @@ Rules baked into that split (same as the web onboarding):
|
||||
- **F-skatt** from the registry is a fact, both true and false.
|
||||
- **VAT** is a fact ONLY when positively registered. "No VAT registration
|
||||
found" is a question, never an assumption (ML 17 kap 24 §).
|
||||
- **Moms period** and **accounting method** are ALWAYS the user's answer.
|
||||
Rules of thumb if unsure: under 1 MSEK turnover may report VAT yearly,
|
||||
under 40 MSEK quarterly, above monthly; cash method only under 3 MSEK.
|
||||
- **Moms period** is ALWAYS the user's answer when VAT-registered. Rules of
|
||||
thumb if unsure: under 1 MSEK turnover may report VAT yearly, under 40
|
||||
MSEK quarterly, above monthly.
|
||||
- **Accounting method is NOT a question**: it defaults by form (AB =
|
||||
faktureringsmetoden, enskild firma = kontantmetoden) and the preview
|
||||
flags the default. Name it in the readback ("faktureringsmetoden,
|
||||
standard för AB, säg till om du vill ha kontantmetoden") so the user can
|
||||
override in the same "ja". Cash requires turnover under 3 MSEK.
|
||||
- **Enskild firma name**: verksamhetsnamnet is freely choosable; suggest the
|
||||
registered name but let the user pick. An AB's registered name is a fact.
|
||||
- **Fiscal year**: registry data becomes a confirm question, never an open
|
||||
|
||||
@@ -34,6 +34,41 @@ describe('CompanySetupSchema', () => {
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults an omitted accounting_method by form and flags it in the plan', () => {
|
||||
// Minimal-input decision 2026-08-26: orgnr + moms period is the whole ask.
|
||||
const ab = CompanySetupSchema.safeParse({ ...base, accounting_method: undefined })
|
||||
expect(ab.success).toBe(true)
|
||||
if (ab.success) {
|
||||
const plan = planCompanySetup(ab.data)
|
||||
expect(plan.ok).toBe(true)
|
||||
if (plan.ok) {
|
||||
expect(plan.resolved).toEqual({ accountingMethod: 'accrual', accountingMethodDefaulted: true })
|
||||
expect(plan.input.settings.accounting_method).toBe('accrual')
|
||||
}
|
||||
}
|
||||
|
||||
const ef = CompanySetupSchema.safeParse({
|
||||
...base,
|
||||
entity_type: 'enskild_firma',
|
||||
accounting_method: undefined,
|
||||
})
|
||||
expect(ef.success).toBe(true)
|
||||
if (ef.success) {
|
||||
const plan = planCompanySetup(ef.data)
|
||||
expect(plan.ok && plan.resolved.accountingMethod).toBe('cash')
|
||||
}
|
||||
})
|
||||
|
||||
it('an explicit accounting_method always wins over the form default', () => {
|
||||
const parsed = CompanySetupSchema.safeParse({ ...base, accounting_method: 'cash' })
|
||||
expect(parsed.success).toBe(true)
|
||||
if (parsed.success) {
|
||||
const plan = planCompanySetup(parsed.data)
|
||||
expect(plan.ok && plan.resolved.accountingMethod).toBe('cash')
|
||||
expect(plan.ok && plan.resolved.accountingMethodDefaulted).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
@@ -29,7 +29,15 @@ export const CompanySetupSchema = z
|
||||
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']),
|
||||
/**
|
||||
* Optional since 2026-08-26 (founder call: minimal onboarding input is
|
||||
* orgnr + moms period). When omitted it defaults by form in
|
||||
* planCompanySetup: aktiebolag → 'accrual' (the norm; kontantmetoden is
|
||||
* rare for AB), enskild firma → 'cash' (the common small-EF choice;
|
||||
* legal below 3 MSEK turnover, BFL 4 kap 4 §). The default is flagged in
|
||||
* the plan so previews present it for confirmation, never silently.
|
||||
*/
|
||||
accounting_method: z.enum(['accrual', 'cash']).optional(),
|
||||
/** 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). */
|
||||
@@ -103,6 +111,8 @@ export type CompanySetupPlan =
|
||||
input: Omit<CreateCompanyInput, 'ticLookup'>
|
||||
/** What the fiscal period resolved to, for previews. */
|
||||
fiscalPeriod: { startDate: string; endDate: string; name: string }
|
||||
/** Values planCompanySetup filled in, so previews can flag them. */
|
||||
resolved: { accountingMethod: 'accrual' | 'cash'; accountingMethodDefaulted: boolean }
|
||||
}
|
||||
| { ok: false; error: string }
|
||||
|
||||
@@ -114,6 +124,7 @@ 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 accountingMethod = setup.accounting_method ?? (isEf ? 'cash' : 'accrual')
|
||||
|
||||
const settings: Record<string, unknown> = {
|
||||
entity_type: setup.entity_type,
|
||||
@@ -122,7 +133,7 @@ export function planCompanySetup(setup: CompanySetup): CompanySetupPlan {
|
||||
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,
|
||||
accounting_method: accountingMethod,
|
||||
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,
|
||||
@@ -151,6 +162,10 @@ export function planCompanySetup(setup: CompanySetup): CompanySetupPlan {
|
||||
fiscalPeriod: { startDate: period.startStr, endDate: period.endStr, name: period.periodName },
|
||||
},
|
||||
fiscalPeriod: { startDate: period.startStr, endDate: period.endStr, name: period.periodName },
|
||||
resolved: {
|
||||
accountingMethod,
|
||||
accountingMethodDefaulted: setup.accounting_method === undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ Creates a new company owned by the API key user (or attached to one of their tea
|
||||
- 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.
|
||||
- accounting_method may be omitted: it then defaults by form (aktiebolag accrual, enskild firma cash) and the response shows the resolved value. The cash default is only legal when turnover normally stays under 3 MSEK (BFL 4 kap 4 §): send accrual explicitly for a larger enskild firma.
|
||||
|
||||
Request body:
|
||||
```ts
|
||||
@@ -63,7 +64,7 @@ Request body:
|
||||
org_number?: string,
|
||||
vat_registered: boolean,
|
||||
moms_period?: "monthly" | "quarterly" | "yearly",
|
||||
accounting_method: "accrual" | "cash",
|
||||
accounting_method?: "accrual" | "cash",
|
||||
f_skatt: boolean,
|
||||
fiscal_year_start_month?: number,
|
||||
first_fiscal_year?: { start: string, end: string },
|
||||
|
||||
Reference in New Issue
Block a user