From 4af74695239d37a2d8b11e653cf181d5f7ba25af Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Wed, 26 Aug 2026 15:15:09 +0200 Subject: [PATCH] feat(onboarding): minimal input: orgnr + moms period is the whole ask (#1952) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + app/api/v1/companies/route.ts | 3 +- claude-plugin/.claude-plugin/plugin.json | 2 +- claude-plugin/commands/setup.md | 2 +- .../__tests__/create-company.test.ts | 19 ++++++++++ .../__tests__/lookup-company.test.ts | 4 ++- extensions/general/mcp-server/server.ts | 27 ++++++++++---- .../general/mcp-server/skills/onboarding.ts | 11 ++++-- .../__tests__/onboarding-input.test.ts | 35 +++++++++++++++++++ lib/company/onboarding-input.ts | 19 ++++++++-- skills/accounted-api/references/core.md | 3 +- 11 files changed, 109 insertions(+), 17 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 98f25ebb..51842f42 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1263,3 +1263,4 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/api/v1/companies/route.ts b/app/api/v1/companies/route.ts index bd784811..709b90fd 100644 --- a/app/api/v1/companies/route.ts +++ b/app/api/v1/companies/route.ts @@ -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, diff --git a/claude-plugin/.claude-plugin/plugin.json b/claude-plugin/.claude-plugin/plugin.json index 6b68a473..31b13a37 100644 --- a/claude-plugin/.claude-plugin/plugin.json +++ b/claude-plugin/.claude-plugin/plugin.json @@ -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)" }, diff --git a/claude-plugin/commands/setup.md b/claude-plugin/commands/setup.md index 01132b35..f78bd425 100644 --- a/claude-plugin/commands/setup.md +++ b/claude-plugin/commands/setup.md @@ -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. diff --git a/extensions/general/mcp-server/__tests__/create-company.test.ts b/extensions/general/mcp-server/__tests__/create-company.test.ts index 085350be..b31e7f96 100644 --- a/extensions/general/mcp-server/__tests__/create-company.test.ts +++ b/extensions/general/mcp-server/__tests__/create-company.test.ts @@ -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 + + expect(result.created).toBe(false) + const preview = result.preview as Record + 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 + const preview = result.preview as Record + 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 diff --git a/extensions/general/mcp-server/__tests__/lookup-company.test.ts b/extensions/general/mcp-server/__tests__/lookup-company.test.ts index e898a163..5a021c8c 100644 --- a/extensions/general/mcp-server/__tests__/lookup-company.test.ts +++ b/extensions/general/mcp-server/__tests__/lookup-company.test.ts @@ -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) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index e96c470e..6357accb 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -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.', } } diff --git a/extensions/general/mcp-server/skills/onboarding.ts b/extensions/general/mcp-server/skills/onboarding.ts index ab83dbfd..ed359896 100644 --- a/extensions/general/mcp-server/skills/onboarding.ts +++ b/extensions/general/mcp-server/skills/onboarding.ts @@ -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 diff --git a/lib/company/__tests__/onboarding-input.test.ts b/lib/company/__tests__/onboarding-input.test.ts index 40f25426..14ef18ba 100644 --- a/lib/company/__tests__/onboarding-input.test.ts +++ b/lib/company/__tests__/onboarding-input.test.ts @@ -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) diff --git a/lib/company/onboarding-input.ts b/lib/company/onboarding-input.ts index 8eddf0dc..7eda0c32 100644 --- a/lib/company/onboarding-input.ts +++ b/lib/company/onboarding-input.ts @@ -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 /** 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 = { 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, + }, } } diff --git a/skills/accounted-api/references/core.md b/skills/accounted-api/references/core.md index 28e8e2b1..691dbb88 100644 --- a/skills/accounted-api/references/core.md +++ b/skills/accounted-api/references/core.md @@ -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 },