Files
accounted/scripts/lib/atom-discovery.ts
T
Jakob Wennberg ccdfed5fea feat: voucher linking, recovery ops, and salary overrides (#591)
* feat: voucher linking, recovery ops, and salary overrides

Adds reversible/correction-style write paths that customers and agents have
been asking for, plus per-run salary employee overrides.

Invoice → voucher linking
- POST /api/invoices/[id]/link-to-voucher and
  GET /api/invoices/[id]/voucher-candidates
- lib/invoices/voucher-matching.ts with full + pg test coverage
- LinkVoucherPicker UI in PaymentBookingDialog
- pending_operations.operation_type expanded with link_invoice_voucher
  (medium risk) and a (journal_entry_id, invoice_id) unique guard
- MCP: gnubok_find_voucher_candidates_for_invoice and
  gnubok_link_invoice_to_voucher tools

SIE undo
- POST /api/import/sie/[id]/undo + undo_sie_import RPC
- sie_imports.status gains 'undone'
- ImportResultStep surfaces the action; structured error SIE_UNDO_FAILED

Edit-recreate journal entries
- POST /api/bookkeeping/journal-entries/[id]/edit-recreate
- Bookkeeping detail page wires it into the existing edit flow

Delete-last-voucher clears IB link
- Trigger + pg test ensure deleting the last voucher of a period nulls the
  opening_balance_journal_entry_id link so a re-import lands cleanly

Salary employee overrides
- salary_run_employees gains per-run override fields + migration
- lib/salary/effective-values.ts centralises resolved values; all payslip,
  payment, AGI, KU, and booking routes read through it
- SalaryOverridePanel on the employee detail page

Account classifier
- lib/bookkeeping/account-classifier.ts + tests; AddAccountDialog uses it
- backfill-import-accounts script updated

Misc
- toast: minor styling tweak
- AGI generate-declaration: respect effective values
- structured-errors: new LINK_INVOICE_VOUCHER namespace

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

* feat: add link_invoice_voucher operation type to pending_operations

* feat: refactor salary run calculations and update error handling for SIE imports

* fix: PR review feedback on voucher linking and SIE recovery

pg-real (blocking):
- tests/pg/delete-last-voucher-ib: drop posted_at = now() from the seed
  UPDATE — journal_entries has no posted_at column.
- lib/invoices/__tests__/voucher-matching.pg: seed the posted voucher
  before closing the fiscal period so enforce_period_lock doesn't block
  the INSERT during setup.

voucher-matching error codes and rollback:
- Add LINK_VOUCHER_DB_ERROR (HTTP 500) and return it on real invoice
  UPDATE / payment INSERT failures. Previously these returned
  LINK_VOUCHER_VOUCHER_NOT_FOUND (404) which the pending-op dispatcher
  auto-rejects on transient DB errors.
- Log rollback failures explicitly so an invoice left in a half-linked
  state (advanced status, no payment row) surfaces for manual
  reconciliation instead of disappearing silently.

resyncNextPeriodOpeningBalance ordering:
- Create the new IB first, relink the period FK, then storno the old IB.
  Previously the storno ran first; if createJournalEntry failed the next
  period was left with a reversed IB and nothing to replace it, and
  executeSIEImport swallows the error as a non-fatal warning.

replace_period_opening_balance_link:
- Tighten role check to owner/admin (was owner/admin/member). Matches
  delete_last_voucher and undo_sie_import.

Data minimisation:
- /api/invoices/[id]/voucher-candidates and the matching MCP tools now
  project only the invoice and customer fields the matcher reads, instead
  of returning the full customer row.

Schema bounds:
- SalaryEmployeeOverrideSchema caps each numeric override at 10 MSEK to
  catch typos before they reach the ledger or AGI.

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

* fix(tests): supply user_id when seeding voucher_sequences

voucher_sequences.user_id is NOT NULL (per the multi-tenant refactor in
20260330130000). The previous test seed only set company_id /
fiscal_period_id / voucher_series, which made the seed fail with a
constraint violation on the latest pg-real run. Pass the same userId
used elsewhere in the seed helper.

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

* fix(tests): scope delete-last-voucher RPC assertions inside the tx

withUserContext always ROLLBACKs, so any DELETE the RPC performs is
discarded when the callback returns. The previous test then queried
journal_entries via a fresh getPool() connection that only saw the
pre-RPC committed seed state — hence "expected '1' to be '0'".

Move every post-RPC assertion (entry count, period FK clear,
opening_balances_set flip, audit log entry, sie_imports clear) inside
the same withUserContext callback so they observe the uncommitted state
before ROLLBACK fires.

Also fix the sie_imports INSERT: the column is `filename`, not
`file_name`, and `sie_type` is NOT NULL.

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

* fix(tests): assert against the IB-marker audit row directly

DELETE on journal_entries fires two audit_log writes: the generic
write_audit_log() trigger row ("Deleted journal_entries record") and the
delete_last_voucher RPC's explicit "(was period IB)" entry. Both land
at the same statement_timestamp(), so ORDER BY created_at DESC LIMIT 1
returned the trigger row non-deterministically in CI.

Switch to a presence check with a LIKE filter on the IB marker so the
test verifies what it actually cares about — that the RPC's IB-aware
audit row exists.

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

* fix(db): set company_id on delete_last_voucher audit_log rows

20260528120000_delete_last_voucher_clears_ib_link.sql inserts directly
into audit_log without setting company_id. audit_log's SELECT policy
filters company_id IN user_company_ids(), so those rows landed with
company_id=NULL and were invisible to every reader — only the generic
write_audit_log() trigger row remained visible. That broke BFL audit-
trail intent: the "(was period IB)" provenance row was never readable.

Republish delete_last_voucher with p_company_id populated on both
audit_log INSERTs (draft path and posted path). Behavior is otherwise
unchanged; the pg-real test for the IB-clear flow now sees the
RPC-written marker row as expected.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Emil <emilmattsson14@gmail.com>
2026-05-28 21:09:43 +02:00

379 lines
13 KiB
TypeScript

/**
* Shared atom discovery + SKILL.md frontmatter parsing.
*
* Used by both:
* - scripts/seed-agent-atom-registry.ts (dev/manual: writes the registry directly)
* - scripts/generate-skill-bodies.ts (production: emits a seed migration)
*
* Keeping discovery in one place means the two paths can never drift on which
* skills count as atoms, how titles/tokens are derived, or how frontmatter is read.
*
* Tiers discovered (the curated set — swarm-* and other Claude-Code-only skills
* are intentionally NOT matched here, so they never become atoms):
* horizontal — `.claude/skills/swedish-*\/SKILL.md` (regulatory)
* vertical — `.claude/skills/industry/<slug>\/SKILL.md` (industry)
* modifier — `.claude/skills/modifier/<slug>\/SKILL.md` (cross-cutting)
*/
import { readdir, readFile, stat } from 'node:fs/promises'
import { join, relative, sep } from 'node:path'
export type Tier = 'horizontal' | 'vertical' | 'modifier'
export interface DiscoveredAtom {
/** Stable id shaped as "<tier>/<slug>" (e.g. "horizontal/swedish-vat"). */
id: string
tier: Tier
slug: string
title: string
description: string
sni_prefixes: string[]
trigger_signals: Record<string, unknown>
/**
* Token estimate over the SKILL.md content ONLY — the unit actually loaded
* into the system prompt / returned by gnubok_load_skill. (We deliberately do
* NOT count references/*.md, which are not read at runtime.)
*/
estimated_tokens: number
/**
* Repo-relative path to the body source — SKILL.md for top-level skills,
* the references/*.md file for reference children (provenance + dev-fallback).
*/
body_path: string
/**
* Body inlined into the DB. For a top-level skill: the raw SKILL.md content
* (frontmatter included) with a "Loadable references" footer appended when the
* skill has any. For a reference child: the raw references/*.md content.
*/
body: string
/**
* NULL for a top-level skill; the parent skill's id for a reference child.
* Reference rows are hidden from every catalog (the metadata index, the MCP
* skill list, the composer atom index, the settings panel) by a
* `parent_atom_id IS NULL` filter — they reach the model only via an explicit
* gnubok_load_skill(<child id>) call after the parent SKILL.md is loaded.
*/
parent_atom_id: string | null
/** Version declared in frontmatter, or 1. The generator may override this. */
frontmatter_version: number
schema_version: number
}
// Normalize CRLF → LF so frontmatter parsing and body inlining are
// platform-independent (Windows checkouts ship .md files with CRLF unless
// .gitattributes forces LF, which it doesn't for *.md).
function normalizeLineEndings(text: string): string {
return text.replace(/\r\n/g, '\n')
}
// ── Frontmatter parsing ────────────────────────────────────────────────
// SKILL.md files use YAML frontmatter with `name`, `description`, and optionally
// `tier`, `sni_prefixes`, `trigger_signals`, `estimated_tokens`, `version`. We
// parse only the keys we care about — js-yaml is not in deps.
interface Frontmatter {
raw: string
name?: string
title?: string
description?: string
tier?: Tier
sniPrefixes?: string[]
triggerSignals?: Record<string, unknown>
estimatedTokens?: number
version?: number
}
function extractFrontmatter(content: string): Frontmatter | null {
const match = content.match(/^---\n([\s\S]*?)\n---/)
if (!match) return null
const raw = match[1]
return {
raw,
name: parseScalar(raw, 'name'),
title: parseScalar(raw, 'title'),
description: parseScalar(raw, 'description'),
tier: parseScalar(raw, 'tier') as Tier | undefined,
sniPrefixes: parseArray(raw, 'sni_prefixes'),
triggerSignals: parseInlineObject(raw, 'trigger_signals'),
estimatedTokens: parseNumber(raw, 'estimated_tokens'),
version: parseNumber(raw, 'version'),
}
}
// Handles `key: value`, `key: "quoted"`, `key: >`+folded, `key: |`+literal.
function parseScalar(yaml: string, key: string): string | undefined {
const inline = new RegExp(`^${escapeKey(key)}:\\s*(.*)$`, 'm').exec(yaml)
if (!inline) return undefined
const head = inline[1].trim()
if (head === '>' || head === '|' || head === '>-' || head === '|-') {
const after = yaml.slice(inline.index + inline[0].length).split('\n')
const lines: string[] = []
for (const line of after) {
if (line.length === 0) continue
if (/^\s/.test(line)) {
lines.push(line.trim())
} else {
break
}
}
return head.startsWith('>') ? lines.join(' ') : lines.join('\n')
}
return unquote(head)
}
function parseNumber(yaml: string, key: string): number | undefined {
const v = parseScalar(yaml, key)
if (v == null) return undefined
const n = Number(v)
return Number.isFinite(n) ? n : undefined
}
function parseArray(yaml: string, key: string): string[] | undefined {
const inline = new RegExp(`^${escapeKey(key)}:\\s*\\[(.*)\\]\\s*$`, 'm').exec(yaml)
if (inline) {
return inline[1]
.split(',')
.map((s) => unquote(s.trim()))
.filter(Boolean)
}
return undefined
}
function parseInlineObject(yaml: string, key: string): Record<string, unknown> | undefined {
// POC: only recognize `trigger_signals: {}` or absent. Deep parsing is deferred.
const line = new RegExp(`^${escapeKey(key)}:\\s*\\{\\s*\\}\\s*$`, 'm').exec(yaml)
if (line) return {}
return undefined
}
function escapeKey(key: string): string {
return key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function unquote(s: string): string {
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
return s.slice(1, -1)
}
return s
}
// ── Token estimation ──────────────────────────────────────────────────
// Chars/4 baseline (Anthropic guidance for English). Swedish text inflates on
// Opus 4.7's tokenizer — re-measure post-POC.
export function estimateTokens(text: string): number {
return Math.ceil(text.length / 4)
}
// ── Title derivation ──────────────────────────────────────────────────
export function deriveTitle(slug: string): string {
// 'swedish-vat' → 'Swedish VAT'; 'swedish-year-end-closing' → 'Swedish Year-End Closing'
return slug
.split('-')
.map((w) => (w === 'vat' || w === 'sru' || w === 'sie' ? w.toUpperCase() : capitalize(w)))
.join(' ')
}
function capitalize(s: string): string {
return s.length === 0 ? s : s[0].toUpperCase() + s.slice(1)
}
// ── Discovery ─────────────────────────────────────────────────────────
/**
* Scan `<rootDir>/.claude/skills/` and return one DiscoveredAtom per skill,
* sorted by id for deterministic output. Skills without frontmatter or without
* a description are skipped (with a warning).
*/
export async function discoverAtoms(rootDir: string): Promise<DiscoveredAtom[]> {
const skillsDir = join(rootDir, '.claude', 'skills')
const rows: DiscoveredAtom[] = []
const entries = await readdir(skillsDir, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory()) continue
// Horizontal: top-level swedish-* directory
if (entry.name.startsWith('swedish-')) {
rows.push(...(await readAtom(rootDir, 'horizontal', entry.name, join(skillsDir, entry.name))))
continue
}
// Vertical / modifier: subdirectories under those names
if (entry.name === 'industry' || entry.name === 'modifier') {
const tier: Tier = entry.name === 'industry' ? 'vertical' : 'modifier'
const tierDir = join(skillsDir, entry.name)
const subs = await readdir(tierDir, { withFileTypes: true })
for (const sub of subs) {
if (!sub.isDirectory()) continue
rows.push(...(await readAtom(rootDir, tier, sub.name, join(tierDir, sub.name))))
}
}
}
rows.sort((a, b) => a.id.localeCompare(b.id))
return rows
}
/**
* Read one skill directory into a top-level atom plus one child atom per
* references/*.md file. Returns an empty array if the SKILL.md is missing or
* lacks the frontmatter we require.
*/
async function readAtom(
rootDir: string,
tier: Tier,
slug: string,
dir: string
): Promise<DiscoveredAtom[]> {
const skillPath = join(dir, 'SKILL.md')
try {
await stat(skillPath)
} catch {
return []
}
const content = normalizeLineEndings(await readFile(skillPath, 'utf8'))
const fm = extractFrontmatter(content)
if (!fm) {
console.warn(` skipped ${relative(rootDir, skillPath)} — no frontmatter`)
return []
}
if (!fm.description) {
console.warn(` skipped ${relative(rootDir, skillPath)} — missing description`)
return []
}
const parentId = `${tier}/${slug}`
const childTier: Tier = fm.tier ?? tier
const refs = await readReferenceFiles(dir)
// Bridge the SKILL.md router (which points at dead `references/*.md` paths at
// runtime) to the loadable child ids the model can actually call. Appended to
// the parent body so it ships in the seeded DB body, visible only once the
// skill itself is loaded.
const body = refs.length > 0 ? content + buildReferencesFooter(parentId, refs) : content
const parent: DiscoveredAtom = {
id: parentId,
tier: childTier,
slug,
title: fm.title ?? deriveTitle(slug),
description: fm.description,
sni_prefixes: fm.sniPrefixes ?? [],
trigger_signals: fm.triggerSignals ?? {},
// Estimate over the loaded unit (SKILL.md + footer), not the whole
// directory — references are loaded separately and budgeted on their own row.
estimated_tokens: fm.estimatedTokens ?? estimateTokens(body),
body_path: relative(rootDir, skillPath),
body,
parent_atom_id: null,
frontmatter_version: fm.version ?? 1,
schema_version: 1,
}
const children: DiscoveredAtom[] = refs.map((r) => ({
id: `${parentId}/${r.slug}`,
tier: childTier,
slug: `${slug}/${r.slug}`,
title: r.descriptor || deriveTitle(r.slug),
description: r.descriptor
? `${r.descriptor} — reference for ${parent.title}`
: `Reference for ${parent.title}`,
sni_prefixes: [],
trigger_signals: {},
estimated_tokens: estimateTokens(r.body),
body_path: relative(rootDir, r.absPath),
body: r.body,
parent_atom_id: parentId,
frontmatter_version: 1,
schema_version: 1,
}))
return [parent, ...children]
}
// ── Reference discovery ───────────────────────────────────────────────
// A skill's deep material lives in <skillDir>/references/**.md. Each file
// becomes a hidden child atom loadable by id; the bytes never count toward the
// parent's token budget and never appear in any catalog listing.
interface ReferenceFile {
/** Absolute path on disk (provenance + dev-fallback anchor). */
absPath: string
/** Path as the SKILL.md router writes it, e.g. "references/bfl-bfnar.md". */
relPath: string
/** Child-id suffix derived from the path, e.g. "bfl-bfnar". */
slug: string
/** Raw file content — the child's DB-inlined body. */
body: string
/** First ATX heading (or first line), used as a human-readable label. */
descriptor: string
}
async function readReferenceFiles(skillDir: string): Promise<ReferenceFile[]> {
const refsDir = join(skillDir, 'references')
try {
await stat(refsDir)
} catch {
return []
}
const files = (await walkMarkdown(refsDir)).sort()
const out: ReferenceFile[] = []
for (const absPath of files) {
const body = normalizeLineEndings(await readFile(absPath, 'utf8'))
const relFromRefs = relative(refsDir, absPath).split(sep).join('/')
out.push({
absPath,
relPath: `references/${relFromRefs}`,
slug: relFromRefs.replace(/\.md$/i, '').replace(/\//g, '-'),
body,
descriptor: firstHeadingOrLine(body),
})
}
return out
}
async function walkMarkdown(dir: string): Promise<string[]> {
const out: string[] = []
const entries = await readdir(dir, { withFileTypes: true })
for (const e of entries) {
const p = join(dir, e.name)
if (e.isDirectory()) out.push(...(await walkMarkdown(p)))
else if (e.isFile() && e.name.toLowerCase().endsWith('.md')) out.push(p)
}
return out
}
function firstHeadingOrLine(md: string): string {
const lines = md.split('\n')
for (const line of lines) {
const h = /^#{1,3}\s+(.+?)\s*#*$/.exec(line.trim())
if (h) return h[1].trim()
}
for (const line of lines) {
const t = line.trim()
if (t.length > 0) return t.slice(0, 100)
}
return ''
}
function buildReferencesFooter(parentId: string, refs: ReferenceFile[]): string {
const lines = [
'',
'---',
'',
'## Loadable references',
'',
'The reference files named above are NOT included in this body. When a question genuinely needs that depth, load the specific one on demand with `gnubok_load_skill` — and only then:',
'',
]
for (const r of refs) {
const label = r.descriptor ? ` — ${r.descriptor}` : ''
lines.push(`- \`${r.relPath}\` → \`gnubok_load_skill("${parentId}/${r.slug}")\`${label}`)
}
return '\n' + lines.join('\n') + '\n'
}