feat(mcp): onboarding flow v2: migration-first SIE import in chat, one-confirm momentum, extended first-year heuristic (#1949)

Three changes from the first real E2E run (Arcim, 2026-08-26):

1. gnubok_sie_preflight: read-only scan of a SIE file shared in chat
   BEFORE anything is staged: parse, validate (per-verifikat balance, IB,
   closed-year P&L residual), CP1252-mojibake tripwire, duplicate
   file/period check, org-number match against the company (the
   wrong-company import is the worst silent failure this flow can have),
   and suggested account mappings shaped for direct passthrough to
   gnubok_import_sie. Both tools now also accept file_content_base64,
   decoded with the same encoding detection as the HTTP upload route so
   CP437 exports keep their åäö.

2. Onboarding skill v2: opens with TWO questions (orgnr + 'vilket system
   hade du innan?'), imports history before the bank (PSD2 rarely reaches
   far enough back), and a momentum rule: the create preview is the ONLY
   stop; connect tools are called without asking, and categorization
   starts as soon as the bank is active. connect_bank instructions now
   describe the account-selection dialog that actually gates the first
   sync, and the bank history cap.

3. deriveFirstYearDefaults: no closed fiscal period in the registry now
   extends the first-year window from 12 to 18 months (BFL 3 kap 3 §):
   a 13-month-old company with no annual report is still in its first,
   extended räkenskapsår (the Arcim case the 12-month rule missed).
   Applied in the web journey and the lookup tool.

tools/list ceiling 61.5K to 62K, documented in the bench.

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 14:16:40 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 52bfd7a399
commit b436d47b64
8 changed files with 516 additions and 90 deletions
@@ -514,7 +514,9 @@ export default function OnboardingJourney({
? parseStartMonthDay(state.ticLookup?.fiscalYear?.startMonthDay)
: null
const firstYearSuggested = state.lookupRan
? deriveFirstYearDefaults(state.ticLookup?.registrationDate).isFirstFiscalYear
? deriveFirstYearDefaults(state.ticLookup?.registrationDate, Date.now(), {
noClosedPeriod: state.ticLookup?.fiscalYear == null,
}).isFirstFiscalYear
: false
const confirmMode = startMonth !== null
const title = confirmMode ? t('journey_fy_confirm_title') : t('journey_fy_ask_title')
@@ -153,6 +153,19 @@ describe('gnubok_lookup_company', () => {
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')
@@ -226,9 +226,13 @@ describe('tools/list payload size guard', () => {
// tools; the onboarding skill's first instruction is to call it, and
// a search-only tool is uncallable on Claude.ai. Descriptions were
// trimmed first; the tool costs ~265 tokens against ~0 headroom.
// * 61.5K to 62K with gnubok_sie_preflight (migration-first onboarding):
// the scan-before-import step the skill instructs for shared SIE
// files, so default-catalog for the same Claude.ai reason; ~390
// tokens (schema carries the mappings-passthrough contract).
// Long-term answer to growth is leaning harder on gnubok_search_tools: if this
// fires again, prefer trimming descriptions or making a tool opt-in via search
// before bumping further.
expect(approxTokens).toBeLessThan(61_500)
expect(approxTokens).toBeLessThan(62_000)
})
})
@@ -0,0 +1,187 @@
/**
* gnubok_sie_preflight: the read-only "does this file look correct" scan
* that runs before gnubok_import_sie. Uses the real SIE parser on inline
* fixture files; only the Supabase lookups (company orgnr, duplicate
* imports, stored mappings) are mocked.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { tools } from '../server'
const tool = tools.find((t) => t.name === 'gnubok_sie_preflight')!
const COMPANY_ID = '11111111-1111-4111-8111-111111111111'
// Current-year fiscal year: a past year would trigger the (correct)
// "omföring av årets resultat saknas" warning and turn verdict ok into
// ok_with_warnings.
const YEAR = new Date().getFullYear()
const VALID_SIE = [
'#FLAGGA 0',
'#FORMAT PC8',
'#SIETYP 4',
'#FNAMN "Testbolaget AB"',
'#ORGNR 556000-0001',
`#RAR 0 ${YEAR}0101 ${YEAR}1231`,
'#KONTO 1930 "Bank"',
'#KONTO 3001 "Försäljning"',
`#VER A 1 ${YEAR}0115 "Faktura 1"`,
'{',
'#TRANS 1930 {} 1000.00',
'#TRANS 3001 {} -1000.00',
'}',
].join('\n')
const UNBALANCED_SIE = VALID_SIE.replace('#TRANS 3001 {} -1000.00', '#TRANS 3001 {} -900.00')
type MockConfig = {
companyOrg?: string | null
duplicateFile?: { id: string; imported_at: string } | null
duplicatePeriod?: Record<string, unknown> | null
}
function mockSupabase(config: MockConfig = {}) {
const makeChain = (table: string) => {
const called: string[] = []
const chain: Record<string | symbol, unknown> = new Proxy(
{},
{
get(_t, prop: string | symbol) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => {
if (table === 'companies') {
resolve({
data: {
org_number: 'companyOrg' in config ? config.companyOrg : '5560000001',
},
error: null,
})
} else if (table === 'sie_imports') {
if (called.includes('single')) {
resolve({ data: config.duplicateFile ?? null, error: null })
} else {
resolve({
data: config.duplicatePeriod ? [config.duplicatePeriod] : [],
error: null,
})
}
} else {
resolve({ data: [], error: null })
}
}
}
return (..._args: unknown[]) => {
called.push(String(prop))
return chain
}
},
}
)
return chain
}
return { from: (table: string) => makeChain(table) }
}
async function run(args: Record<string, unknown>, config?: MockConfig) {
return (await tool.execute(
{ filename: 'export.se', ...args },
COMPANY_ID,
'user-1',
mockSupabase(config) as never
)) as Record<string, unknown>
}
describe('gnubok_sie_preflight', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('is a read-only tool that stages nothing', () => {
expect(tool).toBeDefined()
expect(tool.annotations.readOnlyHint).toBe(true)
expect(tool.annotations.destructiveHint).toBe(false)
})
it('passes a clean matching file with verdict ok and passthrough mappings', async () => {
const result = await run({ file_content: VALID_SIE })
expect(result.verdict).toBe('ok')
const file = result.file as Record<string, unknown>
expect(file.company_name).toBe('Testbolaget AB')
expect(file.voucher_count).toBe(1)
expect((result.org_number_match as Record<string, unknown>).match).toBe(true)
expect(result.duplicate).toBeNull()
// Mappings are shaped for direct passthrough to gnubok_import_sie.
const mappings = result.mappings as Array<Record<string, unknown>>
expect(mappings.length).toBeGreaterThan(0)
expect(mappings[0]).toHaveProperty('sourceAccount')
expect(mappings[0]).toHaveProperty('targetAccount')
})
it('flags an org-number mismatch as another company\'s bookkeeping', async () => {
const result = await run({ file_content: VALID_SIE }, { companyOrg: '5599999999' })
expect(result.verdict).toBe('ok_with_warnings')
expect((result.org_number_match as Record<string, unknown>).match).toBe(false)
expect(result.instructions).toContain('STOP')
})
it('reports unverified instead of ok when the company has no org number', async () => {
const result = await run({ file_content: VALID_SIE }, { companyOrg: null })
const match = result.org_number_match as Record<string, unknown>
expect(match.verified).toBe(false)
expect(match.match).toBeNull()
})
it('returns verdict invalid with the balance error for an unbalanced voucher', async () => {
const result = await run({ file_content: UNBALANCED_SIE })
expect(result.verdict).toBe('invalid')
const validation = result.validation as { errors: string[] }
expect(validation.errors.length).toBeGreaterThan(0)
expect(result.instructions).toContain('do not import')
})
it('returns verdict duplicate when the same file hash is already imported', async () => {
const result = await run(
{ file_content: VALID_SIE },
{ duplicateFile: { id: 'imp-1', imported_at: '2026-08-01T00:00:00Z' } }
)
expect(result.verdict).toBe('duplicate')
expect((result.duplicate as Record<string, unknown>).kind).toBe('file')
})
it('returns verdict duplicate when the fiscal year overlaps a completed import', async () => {
const result = await run(
{ file_content: VALID_SIE },
{
duplicatePeriod: {
id: 'imp-2',
fiscal_year_start: '2025-01-01',
fiscal_year_end: '2025-12-31',
imported_at: '2026-08-01T00:00:00Z',
},
}
)
expect(result.verdict).toBe('duplicate')
expect((result.duplicate as Record<string, unknown>).kind).toBe('period')
})
it('decodes CP437 bytes from file_content_base64 so åäö survive', async () => {
// 'Försäljning' and 'Testbolaget' with CP437 bytes: ö=0x94, ä=0x84.
const cp437 = Buffer.from(
VALID_SIE.replace(/ö/g, '\x94').replace(/ä/g, '\x84').replace(/å/g, '\x86'),
'latin1'
)
const result = await run({ file_content_base64: cp437.toString('base64') })
expect(result.verdict).toBe('ok')
const mappings = result.mappings as Array<{ sourceName?: string }>
expect(mappings.some((m) => m.sourceName === 'Försäljning')).toBe(true)
})
it('rejects a call with neither content field', async () => {
await expect(run({})).rejects.toMatchObject({ code: 'VALIDATION_ERROR' })
})
})
+201 -7
View File
@@ -1444,6 +1444,26 @@ export function isDefaultCatalogTool(tool: { catalogVisibility?: 'default' | 'se
return tool.catalogVisibility !== 'search'
}
/**
* Resolve SIE file content from tool args: plain text (the model read the
* attachment) or base64 (exact bytes, e.g. from a code-execution sandbox).
* The base64 path runs the same encoding detection as the HTTP upload route,
* so CP437 exports keep their åäö instead of arriving pre-mangled through a
* host's UTF-8 read. Returns null when neither field is usable.
*/
async function decodeSieToolContent(args: Record<string, unknown>): Promise<string | null> {
if (typeof args.file_content === 'string' && args.file_content.length > 0) {
return args.file_content
}
if (typeof args.file_content_base64 === 'string' && args.file_content_base64.length > 0) {
const { detectEncoding, decodeBuffer } = await import('@/lib/import/sie-parser')
const buffer = Buffer.from(args.file_content_base64, 'base64')
const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)
return decodeBuffer(arrayBuffer, detectEncoding(arrayBuffer))
}
return null
}
/**
* Absolute origin for links the user opens in a browser. A deployment
* without NEXT_PUBLIC_APP_URL falls back to localhost in getCanonicalBaseUrl,
@@ -3109,14 +3129,22 @@ export const tools: McpTool[] = [
stillToAsk.push('accounting_method (accrual = faktureringsmetoden, cash = kontantmetoden; never guess)')
const startMonth = parseStartMonthDay(lookup.fiscalYear?.startMonthDay)
const firstYear = deriveFirstYearDefaults(lookup.registrationDate)
// No closed fiscal period in the registry = no annual report filed yet
// = still in the first räkenskapsår, which BFL 3 kap 3 § lets run up to
// 18 months. The 12-month window alone misses extended first years.
const firstYear = deriveFirstYearDefaults(lookup.registrationDate, Date.now(), {
noClosedPeriod: lookup.fiscalYear == null,
})
const registrationIso = lookup.registrationDate
? new Date(lookup.registrationDate).toISOString().slice(0, 10)
: null
if (startMonth !== null) {
stillToAsk.push(
`fiscal year: registry shows ${lookup.fiscalYear?.startMonthDay} to ${lookup.fiscalYear?.endMonthDay}; ask "stämmer detta?" instead of an open question`
)
} else if (firstYear.isFirstFiscalYear) {
stillToAsk.push(
`fiscal year: company registered recently; suggest a first fiscal year starting ${firstYear.firstYearStart} (first_fiscal_year start/end; an enskild firma's first year must end 31 December)`
`fiscal year: no closed period in the registry, so this is the FIRST räkenskapsår; suggest first_fiscal_year start ${registrationIso ?? firstYear.firstYearStart} (registration date) and end 31 December (max 18 months from start; an enskild firma's first year must end 31 December), ask only "stämmer det?"`
)
} else {
stillToAsk.push('fiscal year: calendar year or broken year (no registry data)')
@@ -3325,7 +3353,7 @@ export const tools: McpTool[] = [
instructions:
active.length > 0
? 'At least one bank is connected and syncing. To add another bank, give the user the connect_url.'
: 'On claude.ai/Claude Desktop a connect card with an open-in-browser button is rendered with this result; on other clients give the user the connect_url as a link. They must be logged in to Accounted there, pick their bank and approve with BankID; consent lasts up to 180 days and the first transactions arrive within a minute. Tell them to come back here when done, then continue with gnubok_list_uncategorized_transactions.',
: 'On claude.ai/Claude Desktop a connect card with an open-in-browser button is rendered with this result; on other clients give the user the connect_url as a link. They must be logged in to Accounted there, pick their bank, approve with BankID (consent up to 180 days), then CONFIRM WHICH ACCOUNTS to sync in the dialog that opens; the first transactions arrive within a minute of that save. Banks cap PSD2 history (often ~90 days): older history comes via SIE import, not the bank. When the user is back, call this tool again to verify status=active, then continue straight to gnubok_list_uncategorized_transactions without asking.',
}
},
},
@@ -16119,15 +16147,179 @@ export const tools: McpTool[] = [
},
},
{
name: 'gnubok_sie_preflight',
title: 'SIE Preflight Scan',
description:
'Scan a SIE file BEFORE import: parse, validate (balances, IB, encoding), duplicate check, orgnr match against the company, suggested account mappings. Read-only, stages nothing. Call FIRST when the user shares a SIE file; pass the returned mappings to gnubok_import_sie.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
file_content: { type: 'string', description: 'Full SIE file contents as text' },
file_content_base64: { type: 'string', description: 'Exact file bytes base64-encoded (preferred when available: preserves CP437 åäö)' },
filename: { type: 'string' },
},
required: ['filename'],
},
outputSchema: {
type: 'object',
properties: {
verdict: { type: 'string', enum: ['ok', 'ok_with_warnings', 'invalid', 'duplicate'] },
file: { type: 'object' },
validation: { type: 'object' },
org_number_match: { type: ['object', 'null'] },
duplicate: { type: ['object', 'null'] },
mappings: { type: 'array', items: { type: 'object' } },
mapping_stats: { type: 'object' },
instructions: { type: 'string' },
},
required: ['verdict', 'file', 'validation', 'org_number_match', 'duplicate', 'mappings', 'mapping_stats', 'instructions'],
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, _userId, supabase) {
const content = await decodeSieToolContent(args)
if (!content) {
throw Object.assign(
new Error('Provide the SIE file as file_content (text) or file_content_base64 (exact bytes).'),
{ code: 'VALIDATION_ERROR' }
)
}
const { parseSIEFile, validateSIEFile } = await import('@/lib/import/sie-parser')
const { suggestMappings, getMappingStats, isSystemAccount } = await import('@/lib/import/account-mapper')
const { scanSieForCp1252Artifacts, formatSieArtifactWarning } = await import('@/lib/import/sie-artifact-scan')
const { generateImportPreview, checkDuplicateImport, checkDuplicatePeriodImport } = await import('@/lib/import/sie-import')
const { BAS_REFERENCE } = await import('@/lib/bookkeeping/bas-data')
let parsed
try {
parsed = parseSIEFile(content)
} catch (e) {
throw Object.assign(
new Error(`SIE-filen kunde inte tolkas: ${e instanceof Error ? e.message : 'okänt fel'}`),
{ code: 'VALIDATION_ERROR' }
)
}
// Mojibake tripwire (warn, never block): a host that read CP437 bytes
// as UTF-8/Latin-1 mangles åäö in names. Numbers and structure survive,
// so the import still balances; the user decides whether names matter.
const artifactScan = scanSieForCp1252Artifacts(parsed)
const encodingWarnings: string[] = []
if (artifactScan.flagged) {
encodingWarnings.push(
`${formatSieArtifactWarning(artifactScan)} Tip: re-share the file as file_content_base64 to preserve the original bytes.`
)
}
const validation = validateSIEFile(parsed)
// Wrong-company tripwire: importing another company's bookkeeping is
// the worst silent failure this flow can have. Digits-only comparison;
// missing on either side reports unverified instead of ok.
const { data: companyRow } = await supabase
.from('companies')
.select('org_number')
.eq('id', companyId)
.maybeSingle()
const companyOrg = ((companyRow as { org_number?: string | null } | null)?.org_number ?? '').replace(/\D/g, '')
const fileOrg = (parsed.header.orgNumber ?? '').replace(/\D/g, '')
const orgMatch =
companyOrg && fileOrg
? { verified: true, match: companyOrg === fileOrg, company_org_number: companyOrg, file_org_number: fileOrg }
: { verified: false, match: null, company_org_number: companyOrg || null, file_org_number: fileOrg || null }
const duplicateFile = await checkDuplicateImport(supabase, companyId, content)
let duplicatePeriod = null
if (!duplicateFile && parsed.stats.fiscalYearStart && parsed.stats.fiscalYearEnd) {
duplicatePeriod = await checkDuplicatePeriodImport(
supabase,
companyId,
parsed.stats.fiscalYearStart,
parsed.stats.fiscalYearEnd
)
}
const duplicate = duplicateFile
? { kind: 'file', import_id: duplicateFile.id, imported_at: duplicateFile.imported_at }
: duplicatePeriod
? {
kind: 'period',
import_id: duplicatePeriod.id,
fiscal_year_start: duplicatePeriod.fiscal_year_start,
fiscal_year_end: duplicatePeriod.fiscal_year_end,
imported_at: duplicatePeriod.imported_at,
}
: null
const bookkeepingAccounts = parsed.accounts.filter((a) => !isSystemAccount(a.number))
const { data: storedMappings } = await supabase
.from('sie_account_mappings')
.select('*')
.eq('company_id', companyId)
const mappings = suggestMappings(
bookkeepingAccounts,
BAS_REFERENCE,
(storedMappings as import('@/lib/import/types').SIEAccountMappingRecord[]) || undefined
)
const mappingStats = getMappingStats(mappings)
const preview = generateImportPreview(parsed, mappings)
const allWarnings = [...validation.warnings, ...encodingWarnings]
const verdict = !validation.valid
? 'invalid'
: duplicate
? 'duplicate'
: allWarnings.length > 0 || orgMatch.match === false
? 'ok_with_warnings'
: 'ok'
return {
verdict,
file: {
filename: args.filename,
company_name: parsed.header.companyName,
org_number: parsed.header.orgNumber,
sie_type: parsed.header.sieType,
source_program: parsed.header.program ?? null,
fiscal_year: { start: parsed.stats.fiscalYearStart, end: parsed.stats.fiscalYearEnd },
account_count: bookkeepingAccounts.length,
voucher_count: parsed.stats.totalVouchers,
transaction_line_count: parsed.stats.totalTransactionLines,
opening_balance_total: preview.openingBalanceTotal ?? null,
},
validation: { valid: validation.valid, errors: validation.errors, warnings: allWarnings },
org_number_match: orgMatch,
duplicate,
mappings,
mapping_stats: mappingStats,
instructions:
verdict === 'invalid'
? 'The file has blocking errors: report them to the user and do not import. A fresh export from the source system usually fixes them.'
: verdict === 'duplicate'
? 'This file or fiscal year is already imported. Report it; use gnubok_undo_sie_import first if the user wants to replace it.'
: orgMatch.match === false
? 'STOP: the file belongs to a different organisation than this company. Confirm with the user before any import.'
: 'Summarize the scan for the user (source system, fiscal year, voucher count, balance status, any warnings). On their go-ahead call gnubok_import_sie with this same file and the returned mappings; the import stages for approval.',
}
},
},
{
name: 'gnubok_import_sie',
title: 'Import SIE File',
description: 'Stage SIE-file import (types 1-4, CP437/UTF-8/Latin-1). On commit creates fiscal period, opening balances, and journal entries. High-risk, always staged.',
description: 'Stage SIE-file import (types 1-4, CP437/UTF-8/Latin-1). On commit creates fiscal period, opening balances, and journal entries. High-risk, always staged. Run gnubok_sie_preflight first for the scan and the mappings.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
file_content: { type: 'string', description: 'Full SIE file contents' },
file_content_base64: { type: 'string', description: 'Exact file bytes base64-encoded; alternative to file_content (preserves CP437 åäö)' },
filename: { type: 'string', description: 'Original filename' },
mappings: {
type: 'array',
@@ -16141,17 +16333,19 @@ export const tools: McpTool[] = [
opening_balance_series: { type: 'string', description: 'Series for the IB voucher; default avoids series used by the file' },
update_account_names: { type: 'boolean', description: 'Use #KONTO names from the file for created and existing accounts (default true). Set false to keep BAS default names.' },
},
required: ['file_content', 'filename', 'mappings'],
required: ['filename', 'mappings'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
async execute(args, companyId, userId, supabase, actor) {
const fileContent = args.file_content as string
// Decoded once here; the staged params carry the decoded text so
// commitImportSie re-parses exactly what was previewed.
const fileContent = await decodeSieToolContent(args)
const filename = args.filename as string
const mappings = args.mappings as unknown[] | undefined
if (!fileContent || !filename || !Array.isArray(mappings)) {
throw new Error('file_content, filename, and mappings are required')
throw new Error('file_content (or file_content_base64), filename, and mappings are required')
}
// Parse + validate at stage time so the approver sees real content (which
@@ -8,6 +8,14 @@ legally need a human with BankID: connecting (creating the account),
approving the bank consent, and authorising Skatteverket. Everything else
happens here.
**Momentum rule: one confirmation, then keep going.** The only stop in this
flow is the create-company preview ("stämmer detta? ja"). Never end a turn
with "säg till när du vill fortsätta": after the confirm, call the connect
tools immediately; when the user reports a connection done, verify it and
continue straight into import or categorization. Staged writes still go
through their normal approval, but that approval IS the conversation, not an
extra pause around it.
## When to use
- "Sätt upp bokföring för mitt AB / min enskilda firma"
@@ -24,105 +32,103 @@ inget konto skapar du det där (BankID eller e-post), det tar en minut." The
call is retried automatically once connected. Do not send the user to the web
app to sign up first.
## Step 1: ask for the organisationsnummer, then look it up
## Step 1: TWO opening questions, then look up
Ask for ONE thing first: the **organisationsnummer** (10 digits; an enskild
firma's org number is the owner's personnummer, fine to use here). Then call
\`gnubok_lookup_company\` with it. This mirrors the in-app wizard: the public
registry answers most of the questions, so the user confirms facts instead of
filling in a form.
Open with exactly two questions, together:
The result carries three parts; use them exactly as intended:
1. **Organisationsnummer?** (10 digits; an enskild firma's org number is the
owner's personnummer, fine to use here)
2. **Har du bokfört i ett annat system tidigare?** (Fortnox, Visma, Bokio,
Björn Lundén, Briox, Wint, annat system, eller helt nytt bolag)
- \`company\`: the registry facts. Present them as a SHORT summary for the
user to confirm: "Jag hittade Example AB, Storgatan 1 i Stockholm,
godkänd för F-skatt och momsregistrerad. Stämmer det?" Do NOT re-ask
what the registry already answered.
- \`suggested_create_company_input\`: prefilled arguments for
\`gnubok_create_company\`. Merge the user's remaining answers into it.
- \`still_to_ask\`: the questions the registry could not answer. Ask exactly
these and nothing more.
Then call \`gnubok_lookup_company\` with the org number. The registry answers
most of the form; present the facts as a SHORT summary to confirm ("Jag
hittade Example AB, Storgatan 1 i Stockholm, godkänd för F-skatt och
momsregistrerad. Stämmer det?") and ask ONLY what \`still_to_ask\` lists.
Never re-ask what the registry answered.
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** (\`monthly\`/\`quarterly\`/\`yearly\`) and **accounting
method** (\`accrual\`/\`cash\`) are ALWAYS the user's answer. Rules of
thumb if they are unsure: turnover under 1 MSEK may report VAT yearly,
under 40 MSEK quarterly, above that monthly (Skatteverket's registration
decision states the actual period); cash method is only allowed under
3 MSEK turnover and is common for small enskild firma, AB with invoices
usually run accrual.
- **Enskild firma name**: the verksamhetsnamn is freely choosable; suggest
the registered name but let the user pick. An AB's registered name is a
fact.
- **Fiscal year**: when the registry shows one, confirm it ("Ert
räkenskapsår är 1 januari till 31 december, stämmer det?") instead of
asking openly. For a company registered within the last 12 months,
suggest a first fiscal year from the registration date and pass
\`first_fiscal_year\` (BFL 3 kap.: it may be shorter than 12 months or up
to 18 months). Enskild firma is always calendar-year and its first year
always ends 31 December.
- **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.
- **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
one. No closed period in the registry = FIRST räkenskapsår: suggest
registration date to 31 December (up to 18 months, BFL 3 kap 3 §).
If the lookup returns \`not_found\` or \`unavailable\`, fall back to asking
each question in \`still_to_ask\` (the full list) and continue; the flow is
the same, just without prefill. A brand-new registration can take days to
appear in the registry.
\`not_found\`/\`unavailable\`: fall back to asking the \`still_to_ask\` list and
continue. Only \`aktiebolag\` and \`enskild_firma\` are supported today.
Only \`aktiebolag\` and \`enskild_firma\` are supported today; HB/KB/förening
are not. A company marked CEASED in the registry: surface the warning, but
the user may continue (they know their company best).
## Step 2: preview, ONE confirm, create, keep moving
## Step 2: preview, confirm, create
Call \`gnubok_create_company\` WITHOUT \`confirm\`. Read the preview back in
plain Swedish (form, orgnr, fiscal period dates, VAT + period, method).
After the user's "ja": call it again with \`confirm: true\`, and in the SAME
turn continue with step 3 or 4. Creation sets up chart, settings, first
fiscal period, tax deadlines, and starts the 30-day trial; the connection
uses the new company automatically.
Call \`gnubok_create_company\` WITHOUT \`confirm\` first. Read the preview back
to the user in plain Swedish: company form, org number, the fiscal period
dates, VAT setup (registered + period), method. Only after an explicit "ja"
call it again with the same arguments and \`confirm: true\`.
## Step 3 (existing bookkeeping): import it FIRST
What creation does in one step: company + owner membership, BAS chart of
accounts for the company form, settings, the first fiscal period, and the
automatic tax deadlines (moms, F-skatt, AGI, inkomstdeklaration). The 30-day
trial with bank sync, Skatteverket, AI and e-mail starts immediately. This
connection uses the new company automatically from the next call; no
re-authentication.
When the user had a previous system, history comes before the bank: it is
the fastest path to a ledger that shows real value, and bank history rarely
reaches far enough back anyway.
## Step 3: connect the bank
1. Tell them where to export: **Fortnox** Register → Exportera → SIE 4,
**Visma eEkonomi** Bokföring → Export SIE, **Bokio** Inställningar →
Exportera data → SIE, **Björn Lundén / Briox / Wint** under Export.
Every Swedish system exports SIE4 (.se/.sie); ask them to attach the
file here in the chat.
2. When the file arrives, call \`gnubok_sie_preflight\` with its content
(\`file_content\` as read, or \`file_content_base64\` when exact bytes are
available: that preserves åäö in CP437 exports). Summarize the scan:
source system, fiscal years, verifikat count, balance status, org-number
match, warnings. This is the "does it look correct" moment: surface
problems BEFORE anything is written.
3. On their go-ahead: \`gnubok_import_sie\` with the same file and the
preflight's \`mappings\`. It stages for approval; after commit verify with
\`gnubok_get_trial_balance\`.
4. Multiple fiscal years = multiple files: import oldest first so IB/UB
chains. If the file is very large for chat, the web wizard at
\`/import?mode=sie\` is the fallback; Fortnox users can also run the full
API migration (invoices, customers, documents) at
\`/import?mode=migration&provider=fortnox\`.
Call \`gnubok_connect_bank\`. It reports existing connections and returns a
\`connect_url\`; on claude.ai/Claude Desktop a connect card with an
open-in-browser button renders automatically. The user opens the link in a
browser where they are logged in to Accounted, picks the bank and approves
with BankID (PSD2 consent, up to 180 days). Transactions start syncing
within a minute. If they prefer not to connect a bank, they can import bank
statements as files in the web app instead; do not block on this step.
## Step 4: connect bank and Skatteverket (together, no pause)
## Step 4: connect Skatteverket (optional but recommended)
Call \`gnubok_connect_bank\` AND \`gnubok_connect_skatteverket\` in the same
turn; on claude.ai/Desktop both render connect cards with buttons.
Call \`gnubok_connect_skatteverket\`. Same pattern: the user opens the
connect link (card button on claude.ai/Desktop), identifies with BankID as
firmatecknare at Skatteverket, and lands back in Accounted. This enables
skattekonto sync and filing of momsdeklaration and arbetsgivardeklaration
from here. Filing is never mandatory through Accounted: every declaration
can be downloaded and filed manually.
- Bank: BankID + PSD2 consent, then an **account selection dialog** in the
browser: transactions start syncing when the user saves it. Banks cap
PSD2 history (often ~90 days); older history is the SIE import's job.
- Skatteverket: BankID as firmatecknare; enables skattekonto sync and
moms/AGI filing. Optional, never block on it.
## Step 5: first bookkeeping
When the user says they are done (or comes back), re-call
\`gnubok_connect_bank\` to verify \`connected\`, then go DIRECTLY to step 5.
Once transactions arrive: \`gnubok_list_uncategorized_transactions\` and the
categorize flow (\`gnubok_suggest_categories\`, \`gnubok_categorize_transaction\`,
approval). For a company with history in another system, offer the SIE
import (\`gnubok_import_sie\` in the search catalog) before categorizing.
## Step 5: first bookkeeping, immediately
Call \`gnubok_list_uncategorized_transactions\` as soon as the bank is
connected: do not ask whether to proceed. Walk the categorize flow
(\`gnubok_suggest_categories\`, \`gnubok_categorize_transaction\`, approval).
If nothing has synced yet, say so and check again on the user's next
message instead of making them ask.
## Tools
- \`gnubok_lookup_company\`: registry facts + prefill from the orgnr; call first
- \`gnubok_create_company\`: preview (no confirm) then create (confirm=true)
- \`gnubok_list_companies\`: see which companies this connection can reach
- \`gnubok_connect_bank\`: status + connect link for PSD2 bank consent
- \`gnubok_connect_skatteverket\`: status + connect link for Skatteverket
- \`gnubok_get_agent_briefing\`: the company's settings and state once created
- \`gnubok_sie_preflight\`: scan a shared SIE file, nothing written
- \`gnubok_import_sie\`: staged import; use the preflight's mappings
- \`gnubok_connect_bank\` / \`gnubok_connect_skatteverket\`: status + connect links
- \`gnubok_list_companies\`, \`gnubok_get_agent_briefing\`: state checks
- \`gnubok_list_uncategorized_transactions\`: the first real bookkeeping step
## Pitfalls
@@ -131,6 +137,8 @@ import (\`gnubok_import_sie\` in the search catalog) before categorizing.
not work around it by claiming the company is not VAT-registered.
- Do not create a company twice on a retry: check \`gnubok_list_companies\`
if a create call was interrupted.
- A preflight org-number mismatch means the file is another company's
bookkeeping: stop and confirm, never import across companies.
- Bookkeeping duty starts when the company exists in Accounted with a fiscal
period. Never create a company "to try things out" for a real
organisation; use the sandbox in the web app for demos.
@@ -140,8 +148,8 @@ export const onboardingSkill: Skill = {
slug: 'onboarding',
name: 'Onboarding: New Company Setup',
summary:
'Set up a company from the conversation: ask for the orgnr, prefill facts with gnubok_lookup_company, preview and create with gnubok_create_company, then the bank and Skatteverket connect links.',
tags: ['onboarding', 'setup', 'company', 'bank', 'skatteverket', 'agent-first'],
'Set up a company in chat: orgnr + previous system first, prefill via gnubok_lookup_company, one confirm to create, SIE history via preflight + staged import, then bank/Skatteverket cards.',
tags: ['onboarding', 'setup', 'company', 'bank', 'skatteverket', 'sie', 'migration', 'agent-first'],
body,
tier: 'workflow',
}
@@ -46,6 +46,18 @@ describe('deriveFirstYearDefaults', () => {
expect(deriveFirstYearDefaults(registered, NOW).isFirstFiscalYear).toBe(false)
})
it('extends the window to 18 months when the registry shows no closed period', () => {
// Extended first räkenskapsår (BFL 3 kap 3 §): registered 13 months ago
// with no annual report filed is still the first year.
const registered = NOW - 13 * MONTH_MS
expect(
deriveFirstYearDefaults(registered, NOW, { noClosedPeriod: true }).isFirstFiscalYear
).toBe(true)
expect(
deriveFirstYearDefaults(NOW - 19 * MONTH_MS, NOW, { noClosedPeriod: true }).isFirstFiscalYear
).toBe(false)
})
it('seeds first_year_start as the 1st of the UTC registration month', () => {
const registered = new Date('2026-03-14T10:00:00Z').getTime()
const result = deriveFirstYearDefaults(registered, NOW)
+11 -5
View File
@@ -22,16 +22,21 @@ export function parseStartMonthDay(value: string | null | undefined): number | n
/**
* Derive the first-year defaults from TIC's `registrationDate`.
* A company is treated as "first year" when registered less than 12 months
* ago: comfortably inside BFL's 18-month cap on a first räkenskapsår, which
* has no minimum length. Returns both the toggle state and a seeded
* `first_year_start` (always the 1st of the registration month, the format
* the date inputs expect).
* ago, or less than 18 months ago when the registry shows NO closed fiscal
* period (`noClosedPeriod`): a company that has never filed an annual
* report is still in its first räkenskapsår, and BFL 3 kap 3 § allows that
* first year to run up to 18 months (no minimum). The 12-month floor alone
* missed exactly the extended-first-year companies the signal exists for
* (Arcim, registered 13 months before onboarding, first year to 31 Dec).
* Returns both the toggle state and a seeded `first_year_start` (always the
* 1st of the registration month, the format the date inputs expect).
*
* `now` exists for tests; production callers omit it.
*/
export function deriveFirstYearDefaults(
registrationDate: number | null | undefined,
now: number = Date.now(),
opts?: { noClosedPeriod?: boolean },
): {
isFirstFiscalYear: boolean
firstYearStart: string | undefined
@@ -43,8 +48,9 @@ export function deriveFirstYearDefaults(
if (Number.isNaN(regDate.getTime())) {
return { isFirstFiscalYear: false, firstYearStart: undefined }
}
const windowMonths = opts?.noClosedPeriod ? 18 : 12
const monthsAgo = (now - regDate.getTime()) / (1000 * 60 * 60 * 24 * 30.44)
if (monthsAgo >= 12) return { isFirstFiscalYear: false, firstYearStart: undefined }
if (monthsAgo >= windowMonths) return { isFirstFiscalYear: false, firstYearStart: undefined }
const year = regDate.getUTCFullYear()
const month = String(regDate.getUTCMonth() + 1).padStart(2, '0')
return { isFirstFiscalYear: true, firstYearStart: `${year}-${month}-01` }