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:
Jakob Wennberg
2026-08-26 15:15:09 +02:00
committed by GitHub
parent 4ac7b45c8a
commit 4af7469523
11 changed files with 109 additions and 17 deletions
+1
View File
@@ -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.
+2 -1
View File
@@ -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,
+1 -1
View File
@@ -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)"
},
+1 -1
View File
@@ -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)
+20 -7
View File
@@ -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)
+17 -2
View File
@@ -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,
},
}
}
+2 -1
View File
@@ -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 },