@perrylink/dsh-bankrec 0.1.0-alpha.0 → 0.1.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,3 @@
1
1
  # @perrylink/dsh-bankrec
2
2
  Bank CSV/GL ingest, 1:1 matching and 3-sheet reconciliation workpaper — SKU-06 S1 public/synthetic demo. **Partial, not GA**: 0 real firms; user-approved public/synthetic (2026-10-31).
3
- Tools: `bankrec.ingest`, `bankrec.match`, `bankrec.export` (Bank/GL/Matches xlsx). Env `DSH_BANKREC_HOME`. Apache-2.0.
3
+ Tools: `bankrec.ingest`, `bankrec.ingest_camt` (CAMT.053), `bankrec.reconcile_camt`, `bankrec.match`, `bankrec.export` (Bank/GL/Matches xlsx). Env `DSH_BANKREC_HOME`. Apache-2.0.
@@ -0,0 +1,8 @@
1
+ import type { TxnRecord } from '../plugin/storage.js';
2
+ export declare function parseCamt(text: string, accountOverride?: string, evidenceSha?: string): TxnRecord[];
3
+ export interface CamtBalance {
4
+ type: string;
5
+ amount_cents: number;
6
+ date: string;
7
+ }
8
+ export declare function parseCamtBalances(text: string): CamtBalance[];
@@ -0,0 +1,66 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { XMLParser } from 'fast-xml-parser';
3
+ import { BankrecError, cents } from './bankrec.js';
4
+ const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_', removeNSPrefix: true, parseTagValue: false, trimValues: true, processEntities: false });
5
+ const asArray = (value) => Array.isArray(value) ? value : value === undefined ? [] : [value];
6
+ const asObject = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
7
+ function scalar(value) { if (value === null || value === undefined)
8
+ return ''; if (typeof value !== 'object')
9
+ return String(value); const o = value; return String(o['#text'] ?? ''); }
10
+ function dateOf(entry) { const d = scalar(entry.BookgDt?.Dt) || scalar(entry.BookgDt?.DtTm) || scalar(entry.ValDt?.Dt) || scalar(entry.ValDt?.DtTm); return d.slice(0, 10); }
11
+ export function parseCamt(text, accountOverride, evidenceSha) {
12
+ let root;
13
+ try {
14
+ root = parser.parse(text);
15
+ }
16
+ catch {
17
+ throw new BankrecError('E_BR_CSV_INVALID', 'CAMT XML is not parseable');
18
+ }
19
+ const statements = asArray(asObject(root?.Document)?.BkToCstmrStmt?.Stmt);
20
+ if (statements.length === 0)
21
+ throw new BankrecError('E_BR_CSV_INVALID', 'CAMT has no BkToCstmrStmt/Stmt');
22
+ const evidence = evidenceSha ?? createHash('sha256').update(text).digest('hex');
23
+ const out = [];
24
+ for (const stmt of statements) {
25
+ const iban = scalar(stmt?.Acct?.Id?.IBAN) || scalar(stmt?.Acct?.Id?.Othr?.Id);
26
+ const account = accountOverride?.trim() || iban;
27
+ if (!account)
28
+ throw new BankrecError('E_BR_CSV_INVALID', 'CAMT statement has no account/IBAN');
29
+ for (const [index, entry] of asArray(stmt?.Ntry).entries()) {
30
+ const amount = scalar(entry?.Amt);
31
+ const sign = scalar(entry?.CdtDbtInd).toUpperCase() === 'DBIT' ? -1 : 1;
32
+ const value = sign * cents(amount);
33
+ const date = dateOf(entry);
34
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date))
35
+ throw new BankrecError('E_BR_CSV_INVALID', `CAMT entry has no booking date (${index})`);
36
+ const details = entry?.NtryDtls?.TxDtls;
37
+ const reference = scalar(entry?.AcctSvcrRef) || scalar(details?.Refs?.TxId) || scalar(details?.Refs?.EndToEndId) || scalar(details?.RmtInf?.Ustrd) || scalar(details?.RmtInf?.Strd?.CdtrRefInf?.Ref) || 'camt';
38
+ out.push({ txn_id: `camt-${account}-${date}-${value}-${index}`, account_id: account, period: date.slice(0, 7), side: 'bank', date, amount_cents: value, reference, evidence_sha256: evidence, created_at: new Date().toISOString() });
39
+ }
40
+ }
41
+ if (out.length === 0)
42
+ throw new BankrecError('E_BR_CSV_INVALID', 'CAMT has no entries');
43
+ return out;
44
+ }
45
+ export function parseCamtBalances(text) {
46
+ let root;
47
+ try {
48
+ root = parser.parse(text);
49
+ }
50
+ catch {
51
+ throw new BankrecError('E_BR_CSV_INVALID', 'CAMT XML is not parseable');
52
+ }
53
+ const statements = asArray(asObject(root?.Document)?.BkToCstmrStmt?.Stmt);
54
+ const balances = [];
55
+ for (const stmt of statements) {
56
+ for (const bal of asArray(stmt?.Bal)) {
57
+ const type = scalar(bal?.Tp?.CdOrPrtry?.Cd);
58
+ const raw = scalar(bal?.Amt);
59
+ if (!type || !raw)
60
+ continue;
61
+ const sign = scalar(bal?.CdtDbtInd).toUpperCase() === 'DBIT' ? -1 : 1;
62
+ balances.push({ type, amount_cents: sign * cents(raw), date: scalar(bal?.Dt?.Dt) || scalar(bal?.Dt?.DtTm).slice(0, 10) });
63
+ }
64
+ }
65
+ return balances;
66
+ }
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import * as XLSX from 'xlsx';
5
5
  import { defineTool } from '@deepseek-ai/dsh-tools';
6
6
  import { BankrecError, parseCsv } from '../core/bankrec.js';
7
+ import { parseCamt, parseCamtBalances } from '../core/camt.js';
7
8
  const text = (t) => [{ type: 'text', text: t }];
8
9
  const sha = (v) => createHash('sha256').update(v).digest('hex');
9
10
  export function createTools(deps) {
@@ -21,6 +22,10 @@ export function createTools(deps) {
21
22
  }
22
23
  } return { matched, unmatched_bank: bank.filter(b => !usedBank.has(b.txn_id)).map(b => b.txn_id), unmatched_gl: gl.filter(g => !used.has(g.txn_id)).map(g => g.txn_id) }; } });
23
24
  // fix unmatched_bank below via replacement later
25
+ const camt = defineTool({ name: 'bankrec.ingest_camt', description: 'Ingest CAMT.053 XML bank statement entries; supports DBIT/CRDT and stores bank rows. Local R1 write.', parameters: { camt_xml: { type: 'string', required: true }, account_id: { type: 'string' } }, output: { schema: { type: 'object', additionalProperties: false, properties: { rows: { type: 'number', required: true }, accounts: { type: 'array', items: { type: 'string' }, required: true }, periods: { type: 'array', items: { type: 'string' }, required: true } } }, render: (_a, v) => text(`CAMT rows=${v.rows}`) }, async execute(a) { const rows = parseCamt(a.camt_xml, a.account_id); for (const t of rows)
26
+ await deps.transactions.put(t.txn_id, t); return { rows: rows.length, accounts: [...new Set(rows.map(r => r.account_id))], periods: [...new Set(rows.map(r => r.period))] }; } });
24
27
  const exportTool = defineTool({ name: 'bankrec.export', description: 'Export 3-sheet reconciliation xlsx (Bank, GL, Matches). Local R1 write.', parameters: { account_id: { type: 'string', required: true }, period: { type: 'string', required: true } }, output: { schema: { type: 'object', additionalProperties: false, properties: { path: { type: 'string', required: true }, sha256: { type: 'string', required: true }, sheets: { type: 'number', required: true } } }, render: (_a, v) => text(`export ${v.sheets} sheets`) }, async execute(args) { const all = [...deps.transactions.entries()].map(([, v]) => v).filter(t => t.account_id === args.account_id && t.period === args.period); const ms = [...deps.matches.entries()].map(([, v]) => v); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(all.filter(t => t.side === 'bank').map(t => ({ date: t.date, amount_cents: t.amount_cents, reference: t.reference, txn_id: t.txn_id }))), 'Bank'); XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(all.filter(t => t.side === 'gl').map(t => ({ date: t.date, amount_cents: t.amount_cents, reference: t.reference, txn_id: t.txn_id }))), 'GL'); XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(ms), 'Matches'); const dir = path.join(deps.baseDir, 'exports'); await mkdir(dir, { recursive: true }); const p = path.join(dir, `${args.account_id}-${args.period}.xlsx`); const b = Buffer.from(XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })); await writeFile(p, b); return { path: p, sha256: sha(b), sheets: wb.SheetNames.length }; } });
25
- return [ingest, match, exportTool];
28
+ const reconcile = defineTool({ name: 'bankrec.reconcile_camt', description: 'Check CAMT.053 opening/closing balance continuity against the sum of entries; read-only.', parameters: { camt_xml: { type: 'string', required: true } }, output: { schema: { type: 'object', additionalProperties: false, properties: { opening_cents: { type: 'number', required: true }, closing_cents: { type: 'number', required: true }, net_cents: { type: 'number', required: true }, difference_cents: { type: 'number', required: true }, balanced: { type: 'boolean', required: true } } }, render: (_a, v) => text(`CAMT balance ${v.balanced ? 'ok' : 'DIFF'} (${v.difference_cents})`) }, async execute(a) { const balances = parseCamtBalances(a.camt_xml); const rows = parseCamt(a.camt_xml); const open = balances.find(b => /^OPBD|^PRCD/.test(b.type)); const close = balances.find(b => /^CLBD|^CLAV/.test(b.type)); if (!open || !close)
29
+ throw new BankrecError('E_BR_CSV_INVALID', 'CAMT lacks opening/closing balance'); const net = rows.reduce((n, r) => n + r.amount_cents, 0); const diff = open.amount_cents + net - close.amount_cents; return { opening_cents: open.amount_cents, closing_cents: close.amount_cents, net_cents: net, difference_cents: diff, balanced: diff === 0 }; } });
30
+ return [ingest, camt, match, reconcile, exportTool];
26
31
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@perrylink/dsh-bankrec",
3
- "version": "0.1.0-alpha.0",
3
+ "version": "0.1.0-alpha.2",
4
4
  "description": "Bank statement ingest, 1:1 matching and reconciliation workpaper (SKU-06 S1 public/synthetic demo).",
5
5
  "type": "module",
6
6
  "main": "./lib/plugin/index.js",
@@ -61,9 +61,10 @@
61
61
  "@deepseek-ai/dsh-tools": ">=0.2.1-rc.3 <0.3.0"
62
62
  },
63
63
  "dependencies": {
64
+ "fast-xml-parser": "5.11.2",
64
65
  "fflate": "0.8.3",
65
- "zod": "4.4.3",
66
- "xlsx": "0.18.5"
66
+ "xlsx": "0.18.5",
67
+ "zod": "4.4.3"
67
68
  },
68
69
  "devDependencies": {
69
70
  "@deepseek-ai/cordis": "4.0.5",