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>
188 lines
6.4 KiB
TypeScript
188 lines
6.4 KiB
TypeScript
/**
|
|
* 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' })
|
|
})
|
|
})
|