diff --git a/components/onboarding/journey/OnboardingJourney.tsx b/components/onboarding/journey/OnboardingJourney.tsx index ea88094f..cb3f7a4c 100644 --- a/components/onboarding/journey/OnboardingJourney.tsx +++ b/components/onboarding/journey/OnboardingJourney.tsx @@ -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') diff --git a/extensions/general/mcp-server/__tests__/lookup-company.test.ts b/extensions/general/mcp-server/__tests__/lookup-company.test.ts index 6a3586b3..e898a163 100644 --- a/extensions/general/mcp-server/__tests__/lookup-company.test.ts +++ b/extensions/general/mcp-server/__tests__/lookup-company.test.ts @@ -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') diff --git a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts index bb7994c7..7f639562 100644 --- a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts +++ b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts @@ -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) }) }) diff --git a/extensions/general/mcp-server/__tests__/sie-preflight.test.ts b/extensions/general/mcp-server/__tests__/sie-preflight.test.ts new file mode 100644 index 00000000..81c4e158 --- /dev/null +++ b/extensions/general/mcp-server/__tests__/sie-preflight.test.ts @@ -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 | null +} + +function mockSupabase(config: MockConfig = {}) { + const makeChain = (table: string) => { + const called: string[] = [] + const chain: Record = 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, config?: MockConfig) { + return (await tool.execute( + { filename: 'export.se', ...args }, + COMPANY_ID, + 'user-1', + mockSupabase(config) as never + )) as Record +} + +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 + expect(file.company_name).toBe('Testbolaget AB') + expect(file.voucher_count).toBe(1) + expect((result.org_number_match as Record).match).toBe(true) + expect(result.duplicate).toBeNull() + // Mappings are shaped for direct passthrough to gnubok_import_sie. + const mappings = result.mappings as Array> + 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).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 + 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).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).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' }) + }) +}) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index abdc1c18..44f54f50 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -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): Promise { + 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 diff --git a/extensions/general/mcp-server/skills/onboarding.ts b/extensions/general/mcp-server/skills/onboarding.ts index cde6159e..ad258963 100644 --- a/extensions/general/mcp-server/skills/onboarding.ts +++ b/extensions/general/mcp-server/skills/onboarding.ts @@ -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', } diff --git a/lib/company/__tests__/first-year-defaults.test.ts b/lib/company/__tests__/first-year-defaults.test.ts index c5e6ad1a..0162a0e8 100644 --- a/lib/company/__tests__/first-year-defaults.test.ts +++ b/lib/company/__tests__/first-year-defaults.test.ts @@ -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) diff --git a/lib/company/first-year-defaults.ts b/lib/company/first-year-defaults.ts index 019527ab..12f2a722 100644 --- a/lib/company/first-year-defaults.ts +++ b/lib/company/first-year-defaults.ts @@ -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` }