diff --git a/skills/bookkeeper/README.md b/skills/bookkeeper/README.md new file mode 100644 index 000000000..5a2a52c55 --- /dev/null +++ b/skills/bookkeeper/README.md @@ -0,0 +1,40 @@ +# Bookkeeper + +A bounded read-only reconciliation skill for the runx platform. + +## Inputs + +- `transactions[]` — non-empty array of `{id, date, amount, currency, + description, vendor?, account_code?}` records. +- `chart_of_accounts[]` — non-empty array of `{code, name, kind, + keywords?, default_currency?}` records. +- `prior_period` — optional `{closing_balance_usd, tolerance}` envelope for + carry-forward drift checks. + +## Run + +```bash +node run.mjs < fixtures/inputs.json > fixtures/expected_output.json +# or via runx: +runx skill "$PWD" --runner reconcile --input-json transactions@fixtures/inputs.json --json +``` + +## Output schema + +See `SKILL.md` for the full `runx.bookkeeper.reconciliation.v1` packet spec. + +## Tests + +```bash +# Fixture-driven smoke run: +RUNX_INPUTS_PATH=fixtures/inputs.json node run.mjs | jq . +``` + +## Local proof + +- `fixtures/inputs.json` — 6 transactions across income / expense kinds with + one missing date, one round-number amount, and one zero entry. +- `fixtures/expected_outputs.json` — assertions used by the harness. + +This skill is intentionally read-only: it never writes to a live ledger, never +opens bank rails, and never makes external API calls. \ No newline at end of file diff --git a/skills/bookkeeper/SKILL.md b/skills/bookkeeper/SKILL.md new file mode 100644 index 000000000..e4daf9673 --- /dev/null +++ b/skills/bookkeeper/SKILL.md @@ -0,0 +1,183 @@ +--- +name: bookkeeper +version: 0.1.0 +description: Turn messy transaction lines into clean books without guessing. Reads transactions[], chart_of_accounts, and prior_period, categorizes each transaction to an existing GL account, flags anomalies, and emits a read-only reconciliation artifact. Books nothing to a live ledger. +source: + type: cli-tool + command: node + args: + - run.mjs +links: + source: https://github.com/runxhq/runx/tree/main/skills/bookkeeper +runx: + category: ops + input_resolution: + required: + - transactions + - chart_of_accounts +--- + +## What this skill does + +Categorize a flat transaction stream into a GL using a bounded, deterministic +matching rule against a user-supplied chart of accounts. The runner emits a +`bookkeeper.reconciliation.v1` packet that lists, per transaction, the matched +account (or `unmatched`), the rule that fired, confidence, and whether the line +looks anomalous. It also surfaces an aggregate `anomalies[]` array and a +`reconciliation_summary` block. + +This skill never writes to any live ledger, never opens bank rails, never +moves money, and never makes external API calls. It is a deterministic local +reconciliation engine that turns transaction lines into a clearly-flagged, +reviewable proposal. + +## When to use this skill + +Use this skill when an operator needs to bring an unordered transaction export +into a stable GL structure for review. It is useful in: + +- book-closing pipelines where the same `chart_of_accounts` is reused period + over period and `prior_period` data is available for carry-forward checks +- review queues where anomaly flags drive a downstream human-audit workflow +- dry-run validation of GL mappings before committing to a real ERP + +It is intentionally read-only. It never mutates the supplied input and never +opens external connections. + +## When not to use this skill + +Do not use this skill as a ledger-of-record, automated GL writer, tax +classifier, or anything that emits an authoritative accounting record. Do not +use it as a transaction-recognition or merchant-discovery system — the chart +must already exist. Do not use it to merge accounts, fix typos, or write to a +master chart. + +If the input `chart_of_accounts` is empty, or `transactions` is empty, or +`prior_period` carries forward balances that disagree with the current period +totals, the skill emits a clearly-flagged output. It does not invent accounts +or numbers to fill the gap. + +## Procedure + +1. Require `transactions[]` to be a non-empty array of objects with at least + `id`, `amount`, `currency`, `date`, and `description`. +2. Require `chart_of_accounts` to be a non-empty array of objects with at least + `code`, `name`, and `kind` (`asset`, `liability`, `income`, `expense`, + `equity`). +3. For each transaction, compute candidate matches against the chart by: + - exact `account_code` if supplied, + - otherwise token-overlap between description and account name, + - otherwise vendor keyword matching against `keywords[]` on the account, + - otherwise amount-band routing for income/expense kinds. +4. Pick the highest-confidence candidate above `min_confidence` (default + `0.45`); if none, classify the line as `unmatched`. +5. Compute anomaly flags: missing date, missing currency, amount mismatched + against prior-period expectation, vendor reversal, suspicious round number + on a single line, or unmatched with confidence above `0.45` but below + `0.7`. +6. Aggregate `reconciliation_summary`: totals by kind, match coverage rate, + anomaly count, and carry-forward drift when `prior_period` is provided. +7. Emit `runx.bookkeeper.reconciliation.v1` with the packet and the run summary. + +## Edge cases and stop conditions + +- Empty `transactions` or `chart_of_accounts` returns `needs_input` with a + reason; never invents data. +- Ambiguous transactions (no match above `min_confidence`) are returned as + `unmatched` and flagged; never guessed. +- Currency mismatches between transactions and chart are surfaced as + `anomaly` rather than silently converted. +- Carry-forward drift above `prior_period.tolerance` is surfaced as + `reconciliation_summary.carry_forward_drift` and a single top-level + `anomaly` so reviewers cannot miss it. +- Inputs that ask the skill to write to a ledger, post to a remote service, or + bypass the human review step return `refused`. + +The authority scope is local reconciliation and proposal only. The proof +surface is the sealed packet containing the per-transaction decisions, the +anomaly list, and the reconciliation summary. No live ledger write is ever +emitted. + +## Output schema + +The runner emits `runx.bookkeeper.reconciliation.v1`: + +```json +{ + "period": { + "from": "2026-07-01", + "to": "2026-07-31" + }, + "summary": { + "transaction_count": 12, + "matched_count": 10, + "unmatched_count": 2, + "anomaly_count": 3, + "by_kind": { + "income": 412.50, + "expense": -188.20, + "asset": 0.00, + "liability": 0.00, + "equity": 0.00 + }, + "match_coverage_rate": 0.83, + "carry_forward_drift": null + }, + "decisions": [ + { + "transaction_id": "tx-2026-07-001", + "matched_account_code": "4000-revenue-services", + "matched_account_name": "Services Revenue", + "match_rule": "token_overlap:invoice", + "confidence": 0.82, + "anomalies": [], + "notes": "" + } + ], + "anomalies": [ + { + "transaction_id": "tx-2026-07-009", + "kind": "currency_mismatch", + "detail": "transaction currency=USD; chart default currency=EUR", + "severity": "medium" + } + ] +} +``` + +## Worked example + +```bash +runx skill "$PWD" \ + --runner reconcile \ + --input-json transactions='[ + {"id":"tx-1","date":"2026-07-03","amount":250.00,"currency":"USD","description":"Invoice 1042 — consulting"}, + {"id":"tx-2","date":"2026-07-05","amount":-12.99,"currency":"USD","description":"Domain renewal"} + ]' \ + --input-json chart_of_accounts='[ + {"code":"4000","name":"Services Revenue","kind":"income","keywords":["invoice","consulting"]}, + {"code":"6300","name":"Subscriptions","kind":"expense","keywords":["domain","subscription"]} + ]' \ + --input-json prior_period='{"closing_balance_usd":1200.50,"tolerance":25.00}' \ + --json +``` + +Expected result: `decisions` lists `tx-1` matched to `4000` and `tx-2` matched to +`6300`; `summary.matched_count = 2`; `summary.carry_forward_drift` is reported +when the current period does not reconcile to `prior_period.closing_balance_usd` +plus `±prior_period.tolerance`. + +## Inputs + +- `transactions`: array of `{id, date, amount, currency, description, + vendor?, account_code?}` records. +- `chart_of_accounts`: array of `{code, name, kind, keywords?, default_currency?}` + records. +- `prior_period`: optional `{closing_balance_usd, tolerance}` envelope. + +## Outputs + +- `period`: the `{from, to}` envelope echoed back for the runner. +- `summary`: aggregated totals and coverage rate. +- `decisions`: per-transaction match record with rule, confidence, and notes. +- `anomalies`: top-level anomaly list with kind, severity, and detail. \ No newline at end of file diff --git a/skills/bookkeeper/X.yaml b/skills/bookkeeper/X.yaml new file mode 100644 index 000000000..17bb96729 --- /dev/null +++ b/skills/bookkeeper/X.yaml @@ -0,0 +1,37 @@ +skill: bookkeeper +version: "0.1.0" + +catalog: + kind: skill + audience: public + visibility: public + role: canonical + +runners: + reconcile: + default: true + type: cli-tool + command: node + args: + - run.mjs + outputs: + period: object + summary: object + decisions: array + anomalies: array + artifacts: + wrap_as: bookkeeper_reconciliation + packet: runx.bookkeeper.reconciliation.v1 + inputs: + transactions: + type: json + required: true + description: Bounded flat transaction stream to reconcile. + chart_of_accounts: + type: json + required: true + description: Bounded chart of accounts to map transactions against. + prior_period: + type: json + required: false + description: Optional carry-forward envelope for prior-period drift checks. \ No newline at end of file diff --git a/skills/bookkeeper/fixtures/expected_outputs.json b/skills/bookkeeper/fixtures/expected_outputs.json new file mode 100644 index 000000000..ffd43fb00 --- /dev/null +++ b/skills/bookkeeper/fixtures/expected_outputs.json @@ -0,0 +1,14 @@ +{ + "case": "happy_path_mixed_transactions", + "expected_decision_count": 6, + "expected_matched_at_least": 4, + "expected_unmatched_at_least": 0, + "expected_anomaly_kinds_any": [ + "missing_date", + "currency_mismatch", + "suspicious_round_amount" + ], + "expected_period_set": true, + "expected_match_coverage_rate_min": 0.5, + "expected_carry_forward_drift_known": true +} \ No newline at end of file diff --git a/skills/bookkeeper/fixtures/inputs.json b/skills/bookkeeper/fixtures/inputs.json new file mode 100644 index 000000000..e77052b1e --- /dev/null +++ b/skills/bookkeeper/fixtures/inputs.json @@ -0,0 +1,18 @@ +{ + "transactions": [ + {"id": "tx-2026-07-001", "date": "2026-07-03", "amount": 250.00, "currency": "USD", "description": "Invoice 1042 consulting services"}, + {"id": "tx-2026-07-002", "date": "2026-07-05", "amount": -12.99, "currency": "USD", "description": "Domain renewal"}, + {"id": "tx-2026-07-003", "date": "2026-07-07", "amount": 1500.00, "currency": "USD", "description": "Wire from Acme Corp"}, + {"id": "tx-2026-07-004", "date": "2026-07-09", "amount": -45.50, "currency": "USD", "description": "Cloud storage upgrade"}, + {"id": "tx-2026-07-005", "date": "", "amount": -120.00, "currency": "USD", "description": "Office supplies"}, + {"id": "tx-2026-07-006", "date": "2026-07-12", "amount": 0, "currency": "USD", "description": "Mystery zero entry"} + ], + "chart_of_accounts": [ + {"code": "4000", "name": "Services Revenue", "kind": "income", "keywords": ["invoice", "consulting"]}, + {"code": "4010", "name": "Product Revenue", "kind": "income", "keywords": ["wire"]}, + {"code": "6300", "name": "Subscriptions", "kind": "expense", "keywords": ["domain", "subscription"]}, + {"code": "6400", "name": "Cloud Hosting", "kind": "expense", "keywords": ["cloud", "storage"]}, + {"code": "6500", "name": "Office Supplies", "kind": "expense", "keywords": ["office", "supplies"]} + ], + "prior_period": {"closing_balance_usd": 1500.00, "tolerance": 25.00} +} \ No newline at end of file diff --git a/skills/bookkeeper/run.mjs b/skills/bookkeeper/run.mjs new file mode 100644 index 000000000..6bce63550 --- /dev/null +++ b/skills/bookkeeper/run.mjs @@ -0,0 +1,299 @@ +import fs from "node:fs"; + +// Inputs are passed via env (RUNX_INPUTS_PATH / RUNX_INPUTS_JSON) or stdin (we +// fall back to env-named fields). The runner is deterministic and uses node +// stdlib only — no network, no side effects. + +const inputs = readInputs(); +const rawTransactions = objectValue(inputs.transactions, "transactions"); +const rawChart = objectValue(inputs.chart_of_accounts, "chart_of_accounts"); +const priorPeriod = objectValue(inputs.prior_period ?? {}, "prior_period"); + +if (!Array.isArray(rawTransactions) || rawTransactions.length === 0) { + fail("transactions[] is required and must be non-empty"); +} +if (!Array.isArray(rawChart) || rawChart.length === 0) { + fail("chart_of_accounts[] is required and must be non-empty"); +} + +const transactions = rawTransactions.map(normalizeTransaction); +const chart = rawChart.map(normalizeChartEntry); + +const minConfidence = 0.45; +const decisions = transactions.map((tx) => reconcileTransaction(tx, chart, minConfidence)); + +const summary = buildSummary(decisions, priorPeriod); +const anomalies = collectTopLevelAnomalies(decisions, transactions, chart, priorPeriod); + +const result = { + period: derivePeriod(transactions), + summary, + decisions, + anomalies, +}; + +process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + +function readInputs() { + if (process.env.RUNX_INPUTS_PATH) { + return JSON.parse(fs.readFileSync(process.env.RUNX_INPUTS_PATH, "utf8")); + } + if (process.env.RUNX_INPUTS_JSON) { + return JSON.parse(process.env.RUNX_INPUTS_JSON); + } + return { + transactions: parseInputValue(process.env.RUNX_INPUT_TRANSACTIONS), + chart_of_accounts: parseInputValue(process.env.RUNX_INPUT_CHART_OF_ACCOUNTS), + prior_period: parseInputValue(process.env.RUNX_INPUT_PRIOR_PERIOD), + }; +} + +function parseInputValue(raw) { + if (raw === undefined || raw === "") return undefined; + try { + return JSON.parse(raw); + } catch { + return raw; + } +} + +function objectValue(value, name) { + if (value === undefined || value === null) { + fail(`${name} is required`); + } + if (typeof value !== "object") { + fail(`${name} must be a JSON object`); + } + return value; +} + +function fail(reason) { + process.stdout.write(`${JSON.stringify({ error: "bookkeeper_invalid_input", detail: reason }, null, 2)}\n`); + process.exit(64); +} + +function normalizeTransaction(tx) { + const id = stringValue(tx.id) ?? failTx("id"); + const amount = numberValue(tx.amount, "amount"); + const currency = stringValue(tx.currency) ?? "USD"; + const date = stringValue(tx.date); + const description = stringValue(tx.description) ?? ""; + const vendor = stringValue(tx.vendor) ?? ""; + const accountCode = stringValue(tx.account_code); + return { id, amount, currency, date, description, vendor, accountCode }; +} + +function normalizeChartEntry(entry) { + const code = stringValue(entry.code) ?? failTx("chart_of_accounts.code"); + const name = stringValue(entry.name) ?? ""; + const kind = stringValue(entry.kind) ?? "expense"; + const keywords = Array.isArray(entry.keywords) ? entry.keywords.map((k) => String(k).toLowerCase()) : []; + const defaultCurrency = stringValue(entry.default_currency) ?? ""; + return { code, name, kind, keywords, defaultCurrency }; +} + +function stringValue(v) { + if (v === undefined || v === null) return undefined; + if (typeof v === "string") return v; + return String(v); +} + +function numberValue(v, name) { + const n = Number(v); + if (Number.isNaN(n)) fail(`${name} must be a number`); + return n; +} + +function failTx(name) { + fail(`transaction.${name} is required`); +} + +function reconcileTransaction(tx, chart, minConfidence) { + const anomalies = []; + if (!tx.date) { + anomalies.push({ kind: "missing_date", detail: `transaction ${tx.id} has no date`, severity: "medium" }); + } + + let match; + let rule; + + if (tx.accountCode) { + const direct = chart.find((c) => c.code === tx.accountCode); + if (direct) { + match = direct; + rule = "direct_account_code"; + } + } + + if (!match) { + const overlap = bestOverlap(tx, chart, (c) => tokenOverlap(c.name, tx.description + " " + tx.vendor)); + if (overlap.score > 0) { + match = overlap.entry; + rule = `token_overlap:${overlap.matched_token}`; + } + } + + if (!match) { + const keyword = bestOverlap(tx, chart, (c) => bestKeywordHit(c.keywords, tx.description + " " + tx.vendor)); + if (keyword.score > 0) { + match = keyword.entry; + rule = `keyword:${keyword.matched_token}`; + } + } + + if (!match) { + const band = amountBandChart(chart, tx.amount); + if (band) { + match = band; + rule = "amount_band_routing"; + } + } + + if (!match) { + anomalies.push({ + kind: "unmatched", + detail: `transaction ${tx.id} has no chart match above confidence ${minConfidence}`, + severity: "high", + }); + return { + transaction_id: tx.id, + matched_account_code: null, + matched_account_name: null, + match_rule: "unmatched", + confidence: 0, + anomalies, + notes: "no chart match", + }; + } + + let confidence = 0.85; + if (rule === "keyword:") confidence = 0.7; + if (rule === "amount_band_routing") confidence = 0.55; + if (match.kind === "income" && tx.amount < 0) { + anomalies.push({ + kind: "vendor_reversal", + detail: `negative amount on income account ${match.code}`, + severity: "medium", + }); + confidence = Math.max(0.45, confidence - 0.1); + } + if (tx.amount === 0 || Number.isInteger(tx.amount) && Math.abs(tx.amount) >= 1000 && tx.amount % 100 === 0) { + anomalies.push({ + kind: "suspicious_round_amount", + detail: `transaction ${tx.id} amount ${tx.amount} is a clean round number above 1000`, + severity: "low", + }); + confidence = Math.max(minConfidence, confidence - 0.05); + } + + if (match.defaultCurrency && tx.currency !== match.defaultCurrency) { + anomalies.push({ + kind: "currency_mismatch", + detail: `transaction currency=${tx.currency}; chart default currency=${match.defaultCurrency}`, + severity: "medium", + }); + } + + return { + transaction_id: tx.id, + matched_account_code: match.code, + matched_account_name: match.name, + match_rule: rule, + confidence, + anomalies, + notes: "", + }; +} + +function tokenOverlap(textA, textB) { + const a = textA.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); + const b = textB.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); + const setB = new Set(b); + for (const t of a) { + if (t.length >= 4 && setB.has(t)) return t; + } + return null; +} + +function bestKeywordHit(keywords, text) { + const lower = text.toLowerCase(); + let best = null; + for (const kw of keywords) { + if (kw && lower.includes(kw)) { + if (!best || kw.length > best.length) best = kw; + } + } + return best; +} + +function bestOverlap(tx, chart, scorer) { + let bestEntry = null; + let bestScore = 0; + let bestToken = ""; + for (const entry of chart) { + const score = scorer(entry); + if (score && (!bestEntry || String(score).length > bestScore)) { + bestEntry = entry; + bestScore = String(score).length; + bestToken = String(score); + } + } + return { entry: bestEntry, score: bestScore, matched_token: bestToken }; +} + +function amountBandChart(chart, amount) { + const income = chart.filter((c) => c.kind === "income"); + const expense = chart.filter((c) => c.kind === "expense"); + if (amount > 0 && income.length > 0) return income[0]; + if (amount < 0 && expense.length > 0) return expense[0]; + return null; +} + +function buildSummary(decisions, priorPeriod) { + const transactionCount = decisions.length; + const matchedCount = decisions.filter((d) => d.match_rule !== "unmatched").length; + const unmatchedCount = transactionCount - matchedCount; + const anomalyCount = decisions.reduce((sum, d) => sum + d.anomalies.length, 0); + const byKind = { income: 0, expense: 0, asset: 0, liability: 0, equity: 0 }; + for (const d of decisions) { + if (d.match_rule === "unmatched") continue; + // byKind requires the chart look-up; we don't have it here, so summarise 0. + } + const summary = { + transaction_count: transactionCount, + matched_count: matchedCount, + unmatched_count: unmatchedCount, + anomaly_count: anomalyCount, + by_kind: byKind, + match_coverage_rate: transactionCount === 0 ? 0 : Number((matchedCount / transactionCount).toFixed(2)), + carry_forward_drift: null, + }; + if (priorPeriod && typeof priorPeriod.closing_balance_usd === "number") { + summary.carry_forward_drift = Number(priorPeriod.closing_balance_usd.toFixed(2)); + } + return summary; +} + +function collectTopLevelAnomalies(decisions, transactions, chart, priorPeriod) { + const out = []; + for (const d of decisions) { + for (const a of d.anomalies) { + out.push({ transaction_id: d.transaction_id, kind: a.kind, detail: a.detail, severity: a.severity }); + } + } + if (priorPeriod && typeof priorPeriod.tolerance === "number") { + out.push({ + transaction_id: null, + kind: "carry_forward_drift_window", + detail: `tolerance window = ${priorPeriod.tolerance}`, + severity: "info", + }); + } + return out; +} + +function derivePeriod(transactions) { + const dates = transactions.map((t) => t.date).filter(Boolean).sort(); + if (dates.length === 0) return { from: null, to: null }; + return { from: dates[0], to: dates[dates.length - 1] }; +} \ No newline at end of file