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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
52bfd7a399
commit
b436d47b64
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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` }
|
||||
|
||||
Reference in New Issue
Block a user