Files
accounted/lib/import/__tests__/sie-import-derived-ib.test.ts
T
Jakob WennbergandClaude Opus 4.8 bc61862e76 feat(agent): telemetry + CI-gate quick wins from the "AI systems that ship" audit (#677)
* feat(agent): telemetry completeness + durability, CI gates, commit_method provenance

Quick wins from the "Building AI systems that ship" audit:

- mcp.tool_called gains errorMessage (message_sv, truncated 500 chars) on
  all failure exits; new mcp.skill_loaded event on every gnubok_load_skill
  (all tiers) so atom usage is finally measurable
- event_log: (event_type, created_at) index; cleanup cron keeps
  mcp.*/agent.* telemetry 180 days (delivery events stay 30)
- CI: lint ratchet (npm run check:lint — 60 legacy errors baselined,
  fails only on NEW errors) and a pg-real coverage gate (migrations
  touching trigger/RPC/RLS/DEFERRABLE require a *.pg.test.ts change;
  escape hatch: -- pg-test: covered-by/skip)
- journal_entries.commit_method CHECK widened with 'api_key'/'agent';
  the MCP approve path records 'api_key' truthfully instead of
  'user_accept' (agent_first_vision §8 P0-1). 'agent' is reserved — ALL
  MCP traffic (incl. claude.ai OAuth, whose access_token is a minted
  API key) authenticates as api_key today

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(import): derive opening balances from prior-year #UB when SIE lacks #IB (#675)

SIE files exported without #IB 0 rows (only #UB -1) previously imported
with zero opening balances. getEffectiveOpeningBalances() now derives IB
from prior-year UB for balance-sheet accounts when explicit #IB is
absent, surfaces the derivation as an info issue in the import preview,
and excludes share-capital vouchers from opening-balance detection.
Detection regexes are shared between parser and importer so the two
checks cannot drift. 507 lib/import tests pass.

(Authored in a parallel session in this checkout; included per request.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(review): address PR #677 bot findings — RoPA entry, execFileSync, gate scope note

Triage of the compliance-swarm + Greptile findings:

Applied:
- .compliance/ropa.yaml: new mcp.telemetry processing activity declaring
  the 180-day mcp.*/agent.* retention, lawful basis, data categories, and
  the no-args/no-results minimisation (ISO A.8.10, GDPR Art.5(1)(c) —
  the retention split is now formally documented, referenced from the cron)
- check-pg-test-coverage.mjs: execFileSync with argv array — no shell, so
  a hostile base-ref can't inject (ASVS V13.2.1); verified an injection
  attempt exits 2 without executing
- check-pg-test-coverage.mjs: documented the PR-level (not per-migration)
  scope of the gate so reviewers know to check coverage per migration when
  a PR carries several risky migrations (Greptile P2)

Acknowledged, no change:
- errorMessage PII risk: messages are domain-mapped strings; event_log
  already persists far richer delivery payloads under the same RLS; now
  declared in ropa.yaml
- cron error envelope: errorResponse maps to the canonical safe envelope
  and the endpoint is CRON_SECRET-gated
- two-pass delete "partial state": TTL deletes are idempotent — the next
  daily run sweeps whatever a failed pass left behind
- skill_loaded actorLabel/sessionId: mirrors the pre-existing
  mcp.tool_called payload; sessionId is the join key the analytics exist for

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:47:13 +02:00

277 lines
9.0 KiB
TypeScript

/**
* Full-flow regression suite for issue #675.
*
* Some systems export SIE files without current-year #IB 0 records — the
* opening balances exist only implicitly via the SIE continuity invariant
* IB(year 0) = UB(year -1). executeSIEImport must derive the IB from the
* file's #UB -1 records, create a real opening-balance entry whose voucher
* text documents the derivation, and warn the user.
*
* The make-or-break line is the gate in executeSIEImport: it must open on
* the EFFECTIVE opening balances (getEffectiveOpeningBalances), not on raw
* parsed.openingBalances — the raw set is empty for these files.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { executeSIEImport } from '../sie-import'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import type { ParsedSIEFile, AccountMapping } from '../types'
import type { SupabaseClient } from '@supabase/supabase-js'
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: vi.fn(async () => ({ id: 'ob-entry-1' })),
reverseEntry: vi.fn(),
}))
// --- Helpers ---
type QueuedResult = { data?: unknown; error?: unknown; count?: number | null }
/**
* Table-routing supabase mock: each table has its own FIFO of results
* (consumed per .from(table) call), falling back to { data: null, error:
* null } when the queue is empty. Order-independent across tables, so the
* mock doesn't break when an unrelated query is added elsewhere in the flow.
*/
function buildRoutingSupabase(tableQueues: Record<string, QueuedResult[]>) {
const queues = new Map<string, QueuedResult[]>(
Object.entries(tableQueues).map(([k, v]) => [k, [...v]])
)
const makeChain = (result: { data: unknown; error: unknown; count: number | null }): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => resolve(result)
}
return (..._args: unknown[]) => makeChain(result)
},
}
return new Proxy({}, handler)
}
const supabase = {
from: (table: string) => {
const next = queues.get(table)?.shift() ?? {}
return makeChain({
data: next.data ?? null,
error: next.error ?? null,
count: next.count ?? null,
})
},
rpc: async () => ({ data: null, error: null }),
storage: {
from: () => ({ upload: async () => ({ error: null }) }),
},
}
return supabase as unknown as SupabaseClient
}
function makeParsedFile(overrides?: Partial<ParsedSIEFile>): ParsedSIEFile {
return {
header: {
sieType: 4,
flagga: 0,
program: 'TestProg',
programVersion: '1.0',
generatedDate: '2024-01-01',
format: 'PC8',
companyName: 'Continuity AB',
orgNumber: '5566778899',
address: null,
fiscalYears: [
{ yearIndex: 0, start: '2024-01-01', end: '2024-12-31' },
{ yearIndex: -1, start: '2023-01-01', end: '2023-12-31' },
],
currency: 'SEK',
kontoPlanType: null,
},
accounts: [
{ number: '1930', name: 'Företagskonto' },
{ number: '2010', name: 'Eget kapital' },
],
// Issue #675 shape: no #IB 0 at all — only prior-year IB/UB and current UB.
openingBalances: [{ yearIndex: -1, account: '1930', amount: 9483.08 }],
closingBalances: [
{ yearIndex: -1, account: '1930', amount: 37400.78 },
{ yearIndex: -1, account: '2010', amount: -37400.78 },
{ yearIndex: 0, account: '1930', amount: 160406.0 },
{ yearIndex: 0, account: '2010', amount: -160406.0 },
],
resultBalances: [],
vouchers: [],
issues: [],
stats: {
totalAccounts: 2,
totalVouchers: 0,
totalTransactionLines: 0,
fiscalYearStart: '2024-01-01',
fiscalYearEnd: '2024-12-31',
},
...overrides,
}
}
function makeMapping(source: string, target: string): AccountMapping {
return {
sourceAccount: source,
sourceName: `Account ${source}`,
targetAccount: target,
targetName: `Target ${target}`,
confidence: 1,
matchType: 'exact',
isOverride: false,
}
}
function standardQueues() {
return {
sie_imports: [
{ data: null }, // checkDuplicateImport — no duplicate
{}, // cleanupStaleImportRecords delete
{ data: { id: 'imp-1' } }, // createPendingImportRecord insert
{ data: null }, // checkDuplicatePeriodImport — no duplicate
// finalizeImportRecord updates ride on defaults
],
chart_of_accounts: [
{
// syncMappedAccounts paged fetch — both accounts already exist
data: [
{ account_number: '1930', account_name: 'Företagskonto' },
{ account_number: '2010', account_name: 'Eget kapital' },
],
},
],
fiscal_periods: [
{ data: { id: 'fp-1' } }, // find existing fiscal period
{ data: { opening_balances_set: false, opening_balance_entry_id: null } }, // IB-block check
// link update + resync next-period lookup ride on defaults (null)
],
journal_entries: [
{ count: 0 }, // companyHasPriorActivity — first-ever import
],
}
}
const standardOptions = {
filename: 'continuity.se',
fileContent: '#dummy',
createFiscalPeriod: false,
importOpeningBalances: true,
importTransactions: true,
updateAccountNames: false,
}
const standardMappings = [makeMapping('1930', '1930'), makeMapping('2010', '2010')]
// --- Tests ---
describe('executeSIEImport — derived IB from #UB -1 (issue #675)', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('creates the opening-balance entry from #UB -1 when #IB 0 is missing', async () => {
const supabase = buildRoutingSupabase(standardQueues())
const result = await executeSIEImport(
supabase,
'company-1',
'user-1',
makeParsedFile(),
standardMappings,
standardOptions,
)
expect(result.errors).toEqual([])
expect(result.success).toBe(true)
expect(result.openingBalanceEntryId).toBe('ob-entry-1')
expect(result.journalEntriesCreated).toBe(1)
expect(result.warnings.join(' ')).toMatch(/kontinuitetsprincipen/)
expect(createJournalEntry).toHaveBeenCalledTimes(1)
const input = vi.mocked(createJournalEntry).mock.calls[0][3]
expect(input.source_type).toBe('opening_balance')
expect(input.fiscal_period_id).toBe('fp-1')
expect(input.entry_date).toBe('2024-01-01')
expect(input.description).toBe(
'Ingående balanser från SIE-import (härledda från föregående års utgående balans)'
)
expect(input.lines).toEqual([
{ account_number: '1930', debit_amount: 37400.78, credit_amount: 0, line_description: 'IB 1930' },
{ account_number: '2010', debit_amount: 0, credit_amount: 37400.78, line_description: 'IB 2010' },
])
})
it('uses the plain description and no continuity warning for explicit #IB 0', async () => {
const supabase = buildRoutingSupabase(standardQueues())
const parsed = makeParsedFile({
openingBalances: [
{ yearIndex: 0, account: '1930', amount: 37400.78 },
{ yearIndex: 0, account: '2010', amount: -37400.78 },
],
})
const result = await executeSIEImport(
supabase,
'company-1',
'user-1',
parsed,
standardMappings,
standardOptions,
)
expect(result.success).toBe(true)
expect(result.warnings.join(' ')).not.toMatch(/kontinuitetsprincipen/)
const input = vi.mocked(createJournalEntry).mock.calls[0][3]
expect(input.description).toBe('Ingående balanser från SIE-import')
})
it('respects the continuation guard — no derived IB when the company has prior activity', async () => {
const queues = standardQueues()
queues.journal_entries = [{ count: 5 }] // posted entries exist
const supabase = buildRoutingSupabase(queues)
const result = await executeSIEImport(
supabase,
'company-1',
'user-1',
makeParsedFile(),
standardMappings,
standardOptions,
)
expect(createJournalEntry).not.toHaveBeenCalled()
expect(result.openingBalanceEntryId).toBeNull()
expect(result.warnings.join(' ')).toMatch(/hoppades över eftersom bolaget redan har bokförda verifikationer/)
// Zero entries created → the finalizer safety net downgrades the run so
// the file slot stays free for a retry (existing behavior).
expect(result.success).toBe(false)
expect(result.errors.join(' ')).toMatch(/0 verifikationer/)
})
it('creates no IB entry when the file has neither #IB 0 nor #UB -1', async () => {
const supabase = buildRoutingSupabase(standardQueues())
const parsed = makeParsedFile({
openingBalances: [],
closingBalances: [
{ yearIndex: 0, account: '1930', amount: 160406.0 },
{ yearIndex: 0, account: '2010', amount: -160406.0 },
],
})
const result = await executeSIEImport(
supabase,
'company-1',
'user-1',
parsed,
standardMappings,
standardOptions,
)
expect(createJournalEntry).not.toHaveBeenCalled()
expect(result.openingBalanceEntryId).toBeNull()
})
})