jaz-clio 4.20.0 → 4.21.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
@@ -128,7 +128,7 @@ Every command supports `--json` for structured output — ideal for piping to ot
128
128
 
129
129
  ## MCP Server
130
130
 
131
- Expose all 145 CLI tools to AI coding assistants via the [Model Context Protocol](https://modelcontextprotocol.io). The server runs locally on your machine — no cloud, no ports. API calls go directly from your machine to the Jaz API.
131
+ Expose all 146 CLI tools to AI coding assistants via the [Model Context Protocol](https://modelcontextprotocol.io). The server runs locally on your machine — no cloud, no ports. API calls go directly from your machine to the Jaz API.
132
132
 
133
133
  **Claude Code:**
134
134
 
@@ -167,20 +167,17 @@ clio init --platform claude # Force a specific platform
167
167
 
168
168
  See the [full README](https://github.com/teamtinvio/jaz-ai) for skill details, reference file catalogs, and plugin installation.
169
169
 
170
- ## Common API Gotchas
170
+ ## Privacy & Security
171
171
 
172
- Mistakes the CLI and skills prevent:
172
+ Jaz AI runs entirely on your machine. API calls go directly from your machine to the Jaz API over HTTPS. This tool does not include telemetry or collect data. Your API key is stored locally in `~/.config/jaz-clio/credentials.json`.
173
173
 
174
- | Gotcha | Wrong | Right |
175
- |--------|-------|-------|
176
- | Auth header | `Authorization: Bearer ...` | `x-jk-api-key: ...` |
177
- | ID field | `id` | `resourceId` |
178
- | Date field | `issueDate`, `date` | `valueDate` |
179
- | FX currency | `currencyCode: "USD"` | `currency: { sourceCurrency: "USD" }` |
180
- | Org endpoint | `{ data: [...] }` | `{ data: { ... } }` (single object) |
181
- | Payments | `[{ ... }]` | `{ payments: [{ ... }] }` (wrapped) |
182
- | CN Refunds | `{ payments: [{ paymentAmount }] }` | `{ refunds: [{ refundAmount }] }` |
183
- | Apply credits | `{ amount: 100 }` | `{ credits: [{ creditNoteResourceId, amountApplied }] }` |
174
+ See our full privacy policy: [jaz.ai/legal](https://jaz.ai/legal)
175
+
176
+ ## Support
177
+
178
+ - Help center: [help.jaz.ai](https://help.jaz.ai)
179
+ - GitHub Issues: [github.com/teamtinvio/jaz-ai/issues](https://github.com/teamtinvio/jaz-ai/issues)
180
+ - Email: api-support@jaz.ai
184
181
 
185
182
  ## License
186
183
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: jaz-api
3
- version: 4.20.0
3
+ version: 4.21.2
4
4
  description: Complete reference for the Jaz REST API — the accounting platform backend. Use this skill whenever building, modifying, debugging, or extending any code that calls the API — including API clients, integrations, data seeding, test data, or new endpoint work. Contains every field name, response shape, error, gotcha, and edge case discovered through live production testing.
5
5
  license: MIT
6
6
  compatibility: Requires Jaz API key (x-jk-api-key header). Works with Claude Code, Google Antigravity, OpenAI Codex, GitHub Copilot, Cursor, and any agent that reads markdown.
@@ -77,8 +77,8 @@ You are working with the **Jaz REST API** — the accounting platform backend. A
77
77
  31. **Cash transfers use `cashOut`/`cashIn` sub-objects** — NOT flat `fromAccountResourceId`/`toAccountResourceId`. Each: `{ accountResourceId, amount }`.
78
78
 
79
79
  ### Schedulers
80
- 32. **Scheduled invoices/bills wrap in `{ invoice: {...} }` or `{ bill: {...} }`** — not flat. Recurrence field is `repeat` (NOT `frequency`/`interval`). `saveAsDraft: false` required.
81
- 33. **Scheduled journals use FLAT structure** with `schedulerEntries` — not nested in `journal` wrapper.
80
+ 32. **Scheduled invoices/bills wrap in `{ invoice: {...} }` or `{ bill: {...} }`** — not flat. Recurrence field is `repeat` (NOT `frequency`/`interval`). `saveAsDraft: false` required. **`reference` is required** inside the `invoice`/`bill` wrapper — omitting it causes 422.
81
+ 33. **Scheduled journals use FLAT structure** with `schedulerEntries` — not nested in `journal` wrapper. **`valueDate` is required** at the top level (alongside `startDate`, `repeat`, etc.).
82
82
 
83
83
  ### Bookmarks
84
84
  34. **Bookmarks use `items` array wrapper** with `name`, `value`, `categoryCode`, `datatypeCode`.
@@ -113,11 +113,11 @@ You are working with the **Jaz REST API** — the accounting platform backend. A
113
113
  41. **Invoice GET uses `organizationAccountResourceId`** for line item accounts — POST uses `accountResourceId`. Request-side aliases resolve `issueDate` → `valueDate`, `bankAccountResourceId` → `accountResourceId`, etc.
114
114
  42. **Scheduler GET returns `interval`** — POST uses `repeat`. (Response-side asymmetry remains.)
115
115
  43. **Search sort is an object** — `{ sort: { sortBy: ["valueDate"], order: "DESC" } }`. Required when `offset` is present (even `offset: 0`).
116
- 44. **Bank records: two import methods** Multipart CSV/OFX via `POST /magic/importBankStatementFromAttachment` (camelCase fields: `sourceFile`, `accountResourceId`, `businessTransactionType: "BANK_STATEMENT"`, `sourceType: "FILE"` same camelCase rule as rule 59). JSON via `POST /bank-records/:accountResourceId` with `{ records: [{description, netAmount, valueDate, ...}] }`.
116
+ 44. **Bank records** **Create**: Multipart CSV/OFX via `POST /magic/importBankStatementFromAttachment` or JSON via `POST /bank-records/:accountResourceId` with `{ records: [{amount, transactionDate, description?, payerOrPayee?, reference?}] }` (positive = cash-in, negative = cash-out, response: `{data: {errors: []}}`). **Search**: `POST /bank-records/:accountResourceId/search` filter fields: `valueDate` (DateExpression), `status` (StringExpression: UNRECONCILED, RECONCILED, ARCHIVED, POSSIBLE_DUPLICATE), `description`, `extContactName` (payer/payee), `extReference`, `netAmount` (BigDecimalExpression), `extAccountNumber`. Sort by `valueDate` DESC default.
117
117
  45. **Withholding tax** on bills/supplier CNs only. Retry pattern: if `WITHHOLDING_CODE_NOT_FOUND`, strip field and retry.
118
118
  46. **Known API bugs (500s)**: Contact groups PUT, custom fields PUT, capsules POST, catalogs POST, inventory balances GET — all return 500.
119
119
  47. **Non-existent endpoints**: `POST /deposits`, `POST /inventory/adjustments`, `GET /payments` (list), and `POST /payments/search` return 404 — these endpoints are not implemented. To list/search payments, use `POST /cashflow-transactions/search` (the unified transaction ledger — see Rule 63).
120
- 48. **Attachments require PDF/PNG**: `POST /:type/:id/attachments` uses multipart `file` field but rejects `text/plain`. Use `application/pdf` or `image/png`.
120
+ 48. **Attachments require multipart `file` field**: `POST /:type/:id/attachments` expects a binary file in the `file` form-data field NOT a `sourceUrl` text field. Rejects `text/plain`. Use `application/pdf` or `image/png`. CLI: `clio attachments add --file <path>` (local file) or `--url <url>` (downloads first, then uploads as `file`).
121
121
  49. **Currency rate direction: `rate` = functionalToSource (1 base = X foreign)** — POST `rate: 0.74` for a SGD org means 1 SGD = 0.74 USD. **If your data stores rates as "1 USD = 1.35 SGD" (sourceToFunctional), you MUST invert: `rate = 1 / 1.35 = 0.74`.** GET confirms both: `rateFunctionalToSource` (what you POSTed) and `rateSourceToFunctional` (the inverse).
122
122
 
123
123
  ### Search & Filter
@@ -269,6 +269,7 @@ The backend DX overhaul is live. Key improvements now available:
269
269
  - **Field names**: All request bodies use camelCase
270
270
  - **Date serialization**: Python `date` type → `YYYY-MM-DD` strings
271
271
  - **Bill payments**: Embed in bill creation body (safest). Standalone `POST /bills/{id}/payments` also works.
272
- - **Bank records**: Use multipart `POST /magic/importBankStatementFromAttachment`
273
- - **Scheduled bills**: Wrap as `{ status, startDate, endDate, repeat, bill: {...} }`
272
+ - **Bank records**: Create via JSON `POST /bank-records/:id` or multipart `POST /magic/importBankStatementFromAttachment`. Search via `POST /bank-records/:id/search` with filters (valueDate, status, description, extContactName, netAmount, extReference).
273
+ - **Scheduled invoices/bills**: Wrap as `{ status, startDate, endDate, repeat, invoice/bill: { reference, valueDate, dueDate, contactResourceId, lineItems, saveAsDraft: false } }`. `reference` is required.
274
+ - **Scheduled journals**: Flat: `{ status, startDate, endDate, repeat, valueDate, schedulerEntries, reference }`. `valueDate` is required.
274
275
  - **FX currency (invoices, bills, credit notes, AND journals)**: `currency: { sourceCurrency: "USD" }` (auto-fetches platform rate) or `currency: { sourceCurrency: "USD", exchangeRate: 1.35 }` (custom rate). Same object form on all transaction types. **Never use `currencyCode` string** — silently ignored.
@@ -322,10 +322,14 @@ if (acct.code) ctx.coaIds[acct.code] = acct.resourceId;
322
322
 
323
323
  ## Bank Record Errors
324
324
 
325
- ### No JSON POST endpoint exists
326
- There is no JSON POST endpoint for creating bank records. Use multipart import:
325
+ ### Bank record creation
327
326
 
328
- **Workaround**: Use multipart import instead:
327
+ Two methods for creating bank records:
328
+
329
+ **Method 1: JSON POST** (programmatic)
330
+ `POST /api/v1/bank-records/:accountResourceId` with `{ records: [{amount, transactionDate, description?, payerOrPayee?, reference?}] }`. Returns `{ data: { errors: [] } }` on success. 1-100 records per call.
331
+
332
+ **Method 2: Multipart import** (file upload)
329
333
  ```
330
334
  POST /api/v1/magic/importBankStatementFromAttachment
331
335
  Content-Type: multipart/form-data
@@ -337,12 +341,7 @@ Fields:
337
341
  - sourceType: "FILE" (valid values: URL, FILE)
338
342
  ```
339
343
 
340
- Production clients use this multipart endpoint exclusively. Multipart import is the more reliable method.
341
-
342
- **Legacy guidance** (still valid for general bank record handling):
343
- 1. Always use `Math.abs()` on amounts — amounts must be positive
344
- 2. Use `type: "CREDIT"` or `type: "DEBIT"` to indicate direction
345
- 3. Check that `bankAccountResourceId` is a valid CoA entry with `accountType: "Bank Accounts"`
344
+ **Amount rules**: For JSON POST, positive = cash-in, negative = cash-out. Dates in YYYY-MM-DD format. `accountResourceId` must be a bank-type CoA account.
346
345
 
347
346
  ---
348
347
 
@@ -326,12 +326,20 @@ DELETE → expects "A" (parentEntityResourceId, via /cashflow-journals/:id)
326
326
 
327
327
  | What You'd Guess | Actual API Field | Notes |
328
328
  |------------------|-------------------|-------|
329
- | `date` | `transactionDate` | ISO date string |
330
- | `direction` | `type` | `"CREDIT"` or `"DEBIT"` (uppercase) |
329
+ | `date` (create) | `transactionDate` | ISO date string (YYYY-MM-DD) |
330
+ | `date` (search response) | `valueDate` | Returned as ISO 8601 UTC |
331
+ | `amount` (create) | `amount` | Positive = cash-in, negative = cash-out |
332
+ | `amount` (search response) | `netAmount` | Same sign convention |
333
+ | `payer` / `payee` (create) | `payerOrPayee` | Free text |
334
+ | `payer` / `payee` (search) | `extContactName` | Search filter field |
335
+ | `reference` (search) | `extReference` | Search filter field |
331
336
  | `memo` | `description` | Free text |
332
- | (negative amount) | `amount` (positive) + `type` | Always positive, direction via type |
337
+ | `source` | `bankStatementEntrySource` | `FILE_IMPORT` or `API` (read-only) |
338
+ | `status` | `status` | `UNRECONCILED`, `RECONCILED`, `ARCHIVED`, `POSSIBLE_DUPLICATE` |
333
339
 
334
- **Creating bank records**: Use multipart `POST /api/v1/magic/importBankStatementFromAttachment` the only endpoint for creating bank records. No JSON POST endpoint exists.
340
+ **Creating bank records**: Two methods. JSON POST via `POST /bank-records/:accountResourceId` with `{ records: [{amount, transactionDate, ...}] }` (1-100 records). Multipart via `POST /magic/importBankStatementFromAttachment` for CSV/OFX file uploads.
341
+
342
+ **Searching bank records**: `POST /bank-records/:accountResourceId/search` with filter fields: `valueDate` (DateExpression), `status` (StringExpression), `description` (StringExpression), `extContactName` (StringExpression), `extReference` (StringExpression), `netAmount` (BigDecimalExpression), `extAccountNumber` (StringExpression). Sort by `valueDate` DESC default.
335
343
 
336
344
  ---
337
345
 
@@ -510,7 +518,7 @@ Battle-tested patterns from production Jaz API clients:
510
518
  | Alias generator | `alias_generator=to_camel` (snake_case to camelCase) |
511
519
  | Date type | Python `date` type serializes to `YYYY-MM-DD` strings |
512
520
  | Bill payments | Always embedded in bill creation body, never standalone |
513
- | Bank records | Multipart import via `importBankStatementFromAttachment` the only endpoint |
521
+ | Bank records | JSON POST `/bank-records/:id` or multipart `/magic/importBankStatementFromAttachment`. Search via `/bank-records/:id/search`. |
514
522
  | Scheduled bills | Wrapped as `{ repeat, startDate, endDate, bill: {...} }`. Field is `repeat` (NOT `frequency`/`interval`) |
515
523
  | FX currency | MUST use `currency` OBJECT on ALL transaction types (invoices, bills, credit notes, journals): `{ sourceCurrency: "USD" }` (auto platform rate) or `{ sourceCurrency: "USD", exchangeRate: 1.35 }` (custom). String `currencyCode` silently ignored. |
516
524
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: jaz-conversion
3
- version: 4.20.0
3
+ version: 4.21.2
4
4
  description: Accounting data conversion skill — migrates customer data from Xero, QuickBooks, Sage, MYOB, and Excel exports to Jaz. Covers config, quick, and full conversion workflows, Excel parsing, CoA/contact/tax/items mapping, clearing accounts, TTB, and TB verification.
5
5
  ---
6
6
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: jaz-jobs
3
- version: 4.20.0
3
+ version: 4.21.2
4
4
  description: 12 accounting jobs for SMB bookkeepers and accountants — month-end, quarter-end, and year-end close playbooks plus 9 ad-hoc operational jobs (bank recon, document collection, GST/VAT filing, payment runs, credit control, supplier recon, audit prep, fixed asset review, statutory filing). Jobs can have paired tools as nested subcommands (e.g., `clio jobs bank-recon match`, `clio jobs document-collection ingest`, `clio jobs statutory-filing sg-cs`). Paired with an interactive CLI blueprint generator (clio jobs).
5
5
  license: MIT
6
6
  compatibility: Works with Claude Code, Claude Cowork, Claude.ai, and any agent that reads markdown. For API payloads, load the jaz-api skill. For individual transaction patterns, load the jaz-recipes skill.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: jaz-recipes
3
- version: 4.20.0
3
+ version: 4.21.2
4
4
  description: 16 IFRS-compliant recipes for complex multi-step accounting in Jaz — prepaid amortization, deferred revenue, loan schedules, IFRS 16 leases, hire purchase, fixed deposits, asset disposal, FX revaluation, ECL provisioning, IAS 37 provisions, dividends, intercompany, and capital WIP. Each recipe includes journal entries, capsule structure, and verification steps. Paired with 13 financial calculators that produce execution-ready blueprints with workings.
5
5
  license: MIT
6
6
  compatibility: Works with Claude Code, Claude Cowork, Claude.ai, and any agent that reads markdown. For API payloads, load the jaz-api skill alongside this one.
@@ -1,7 +1,21 @@
1
1
  import chalk from 'chalk';
2
+ import { readFileSync } from 'node:fs';
3
+ import { basename, extname, resolve } from 'node:path';
2
4
  import { listAttachments, addAttachment, fetchAttachmentTable } from '../core/api/attachments.js';
3
5
  import { apiAction } from './api-action.js';
4
6
  const BT_TYPES = ['invoices', 'bills', 'journals', 'scheduled_journals', 'customer-credit-notes', 'supplier-credit-notes'];
7
+ const ATTACHMENT_MIME_MAP = {
8
+ '.pdf': 'application/pdf',
9
+ '.png': 'image/png',
10
+ '.jpg': 'image/jpeg',
11
+ '.jpeg': 'image/jpeg',
12
+ '.gif': 'image/gif',
13
+ '.webp': 'image/webp',
14
+ '.svg': 'image/svg+xml',
15
+ '.csv': 'text/csv',
16
+ '.xls': 'application/vnd.ms-excel',
17
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
18
+ };
5
19
  export function registerAttachmentsCommand(program) {
6
20
  const attachments = program
7
21
  .command('attachments')
@@ -35,11 +49,12 @@ export function registerAttachmentsCommand(program) {
35
49
  // ── clio attachments add ───────────────────────────────────────
36
50
  attachments
37
51
  .command('add')
38
- .description('Add an attachment to a transaction')
52
+ .description('Add an attachment to a transaction (file upload or URL download)')
39
53
  .requiredOption('--type <type>', `Transaction type (${BT_TYPES.join(', ')})`)
40
54
  .requiredOption('--id <resourceId>', 'Transaction resourceId')
41
- .option('--attachment-id <id>', 'Attachment ID')
42
- .option('--url <url>', 'Source file URL')
55
+ .option('--file <path>', 'Local file path (PDF, PNG, JPG, etc.)')
56
+ .option('--url <url>', 'Download file from URL and attach')
57
+ .option('--attachment-id <id>', 'Link existing attachment by ID')
43
58
  .option('--api-key <key>', 'API key')
44
59
  .option('--json', 'Output as JSON')
45
60
  .action(apiAction(async (client, opts) => {
@@ -48,15 +63,41 @@ export function registerAttachmentsCommand(program) {
48
63
  console.error(chalk.red(`Invalid type. Use one of: ${BT_TYPES.join(', ')}`));
49
64
  process.exit(1);
50
65
  }
51
- if (!opts.attachmentId && !opts.url) {
52
- console.error(chalk.red('Provide --attachment-id or --url'));
66
+ if (!opts.attachmentId && !opts.url && !opts.file) {
67
+ console.error(chalk.red('Provide --file, --url, or --attachment-id'));
53
68
  process.exit(1);
54
69
  }
70
+ let file;
71
+ let fileName;
72
+ if (opts.file) {
73
+ // Local file upload
74
+ const filePath = resolve(opts.file);
75
+ const ext = extname(filePath).toLowerCase();
76
+ const mime = ATTACHMENT_MIME_MAP[ext] ?? 'application/octet-stream';
77
+ const buffer = readFileSync(filePath);
78
+ file = new Blob([buffer], { type: mime });
79
+ fileName = basename(filePath);
80
+ }
81
+ else if (opts.url) {
82
+ // Download from URL, then upload as file
83
+ const res = await fetch(opts.url);
84
+ if (!res.ok) {
85
+ console.error(chalk.red(`Failed to download: ${res.status} ${res.statusText}`));
86
+ process.exit(1);
87
+ }
88
+ const buffer = await res.arrayBuffer();
89
+ const contentType = res.headers.get('content-type') ?? 'application/octet-stream';
90
+ file = new Blob([buffer], { type: contentType });
91
+ // Extract filename from URL path
92
+ const urlPath = new URL(opts.url).pathname;
93
+ fileName = basename(urlPath) || 'attachment';
94
+ }
55
95
  const result = await addAttachment(client, {
56
96
  businessTransactionType: btType,
57
97
  businessTransactionResourceId: opts.id,
98
+ file,
99
+ fileName,
58
100
  attachmentId: opts.attachmentId,
59
- sourceUrl: opts.url,
60
101
  });
61
102
  if (opts.json) {
62
103
  console.log(JSON.stringify(result, null, 2));
@@ -1,7 +1,8 @@
1
1
  import chalk from 'chalk';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { basename, extname, resolve } from 'node:path';
4
- import { listBankAccounts, getBankAccount, searchBankRecords, importBankStatement, } from '../core/api/bank.js';
4
+ import { listBankAccounts, getBankAccount, searchBankRecords, addBankRecords, importBankStatement, } from '../core/api/bank.js';
5
+ import { buildBankRecordFilter } from '../core/registry/pagination.js';
5
6
  import { apiAction } from './api-action.js';
6
7
  import { parsePositiveInt } from './parsers.js';
7
8
  const BANK_MIME_MAP = {
@@ -61,27 +62,31 @@ export function registerBankCommand(program) {
61
62
  .description('Search bank records for a bank account')
62
63
  .option('--from <YYYY-MM-DD>', 'Filter from date (inclusive)')
63
64
  .option('--to <YYYY-MM-DD>', 'Filter to date (inclusive)')
64
- .option('--status <status>', 'Filter by status (UNRECONCILED, RECONCILED)')
65
+ .option('--status <status>', 'Filter by status (UNRECONCILED, RECONCILED, ARCHIVED, POSSIBLE_DUPLICATE)')
66
+ .option('--description <text>', 'Filter by description (contains)')
67
+ .option('--payer <name>', 'Filter by payer/payee name (contains)')
68
+ .option('--reference <ref>', 'Filter by reference (contains)')
69
+ .option('--amount-min <n>', 'Filter by minimum amount', parseFloat)
70
+ .option('--amount-max <n>', 'Filter by maximum amount', parseFloat)
65
71
  .option('--limit <n>', 'Max results (default 50)', parsePositiveInt)
66
72
  .option('--api-key <key>', 'API key (overrides stored/env)')
67
73
  .option('--json', 'Output as JSON')
68
74
  .action((accountResourceId, opts) => apiAction(async (client) => {
69
- const filter = {};
70
- if (opts.status)
71
- filter.status = { eq: opts.status };
72
- if (opts.from || opts.to) {
73
- const dateFilter = {};
74
- if (opts.from)
75
- dateFilter.gte = opts.from;
76
- if (opts.to)
77
- dateFilter.lte = opts.to;
78
- filter.date = dateFilter;
79
- }
75
+ const filter = buildBankRecordFilter({
76
+ status: opts.status,
77
+ from: opts.from,
78
+ to: opts.to,
79
+ description: opts.description,
80
+ payer: opts.payer,
81
+ reference: opts.reference,
82
+ amountMin: opts.amountMin,
83
+ amountMax: opts.amountMax,
84
+ });
80
85
  const res = await searchBankRecords(client, accountResourceId, {
81
- filter: Object.keys(filter).length > 0 ? filter : undefined,
86
+ filter,
82
87
  limit: opts.limit ?? 50,
83
88
  offset: 0,
84
- sort: { sortBy: ['date'], order: 'DESC' },
89
+ sort: { sortBy: ['valueDate'], order: 'DESC' },
85
90
  });
86
91
  if (opts.json) {
87
92
  console.log(JSON.stringify(res, null, 2));
@@ -93,10 +98,49 @@ export function registerBankCommand(program) {
93
98
  }
94
99
  console.log(chalk.bold(`Bank Records (${res.data.length} of ${res.totalElements}):\n`));
95
100
  for (const r of res.data) {
96
- const amount = r.amount >= 0
97
- ? chalk.green(`+${r.amount.toFixed(2)}`)
98
- : chalk.red(r.amount.toFixed(2));
99
- console.log(` ${chalk.cyan(r.resourceId)} ${r.date} ${amount} ${r.description} ${chalk.dim(r.status)}`);
101
+ const net = typeof r.netAmount === 'number' && Number.isFinite(r.netAmount) ? r.netAmount : 0;
102
+ const amount = net >= 0
103
+ ? chalk.green(`+${net.toFixed(2)}`)
104
+ : chalk.red(net.toFixed(2));
105
+ const payer = r.extContactName ? ` ${chalk.yellow(r.extContactName)}` : '';
106
+ console.log(` ${chalk.cyan(r.resourceId)} ${r.valueDate} ${amount} ${r.description ?? ''}${payer} ${chalk.dim(r.status)}`);
107
+ }
108
+ }
109
+ })(opts));
110
+ // ── clio bank add-records ──────────────────────────────────────
111
+ bank
112
+ .command('add-records <accountResourceId>')
113
+ .description('Add bank records via JSON (1-100 records per call)')
114
+ .requiredOption('--records <json>', 'JSON array: [{amount, transactionDate, description?, payerOrPayee?, reference?}]')
115
+ .option('--api-key <key>', 'API key (overrides stored/env)')
116
+ .option('--json', 'Output as JSON')
117
+ .action((accountResourceId, opts) => apiAction(async (client) => {
118
+ const parsed = JSON.parse(opts.records);
119
+ if (!Array.isArray(parsed))
120
+ throw new Error('--records must be a JSON array');
121
+ if (parsed.length < 1 || parsed.length > 100)
122
+ throw new Error('--records must contain 1-100 entries');
123
+ for (const [i, r] of parsed.entries()) {
124
+ if (typeof r !== 'object' || r === null)
125
+ throw new Error(`records[${i}] must be an object`);
126
+ if (typeof r.amount !== 'number' || !Number.isFinite(r.amount))
127
+ throw new Error(`records[${i}].amount must be a finite number`);
128
+ if (typeof r.transactionDate !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(r.transactionDate))
129
+ throw new Error(`records[${i}].transactionDate must be YYYY-MM-DD`);
130
+ }
131
+ const res = await addBankRecords(client, accountResourceId, parsed);
132
+ const errors = Array.isArray(res?.data?.errors) ? res.data.errors : [];
133
+ if (opts.json) {
134
+ console.log(JSON.stringify(res.data, null, 2));
135
+ }
136
+ else {
137
+ if (errors.length > 0) {
138
+ console.log(chalk.red(`${errors.length} error(s):`));
139
+ for (const e of errors)
140
+ console.log(` ${chalk.red(String(e))}`);
141
+ }
142
+ else {
143
+ console.log(chalk.green(`Created ${parsed.length} bank record(s).`));
100
144
  }
101
145
  }
102
146
  })(opts));
@@ -28,7 +28,7 @@ export function registerExportsCommand(program) {
28
28
  params.startDate = opts.startDate;
29
29
  if (opts.endDate) {
30
30
  // Point-in-time exports (balance-sheet, trial-balance) use snapshotDate, not endDate
31
- const pointInTime = ['balance-sheet', 'trial-balance'];
31
+ const pointInTime = ['balance-sheet'];
32
32
  if (pointInTime.includes(exportType)) {
33
33
  params.snapshotDate = opts.endDate;
34
34
  }
@@ -113,6 +113,7 @@ export function registerSchedulersCommand(program) {
113
113
  { flag: '--start-date', key: 'startDate' },
114
114
  { flag: '--repeat', key: 'repeat' },
115
115
  { flag: '--contact', key: 'contact' },
116
+ { flag: '--reference', key: 'reference' },
116
117
  { flag: '--value-date', key: 'valueDate' },
117
118
  { flag: '--due-date', key: 'dueDate' },
118
119
  { flag: '--line-items', key: 'lineItems' },
@@ -170,6 +171,7 @@ export function registerSchedulersCommand(program) {
170
171
  { flag: '--start-date', key: 'startDate' },
171
172
  { flag: '--repeat', key: 'repeat' },
172
173
  { flag: '--contact', key: 'contact' },
174
+ { flag: '--reference', key: 'reference' },
173
175
  { flag: '--value-date', key: 'valueDate' },
174
176
  { flag: '--due-date', key: 'dueDate' },
175
177
  { flag: '--line-items', key: 'lineItems' },
@@ -224,6 +226,7 @@ export function registerSchedulersCommand(program) {
224
226
  requireFields(opts, [
225
227
  { flag: '--start-date', key: 'startDate' },
226
228
  { flag: '--repeat', key: 'repeat' },
229
+ { flag: '--value-date', key: 'valueDate' },
227
230
  { flag: '--entries', key: 'entries' },
228
231
  ]);
229
232
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- user-provided JSON, API validates
@@ -233,6 +236,7 @@ export function registerSchedulersCommand(program) {
233
236
  startDate: opts.startDate,
234
237
  endDate: opts.endDate,
235
238
  repeat: opts.repeat,
239
+ valueDate: opts.valueDate,
236
240
  schedulerEntries,
237
241
  reference: opts.reference,
238
242
  notes: opts.notes,
@@ -8,10 +8,10 @@ export async function listAttachments(client, btType, btResourceId) {
8
8
  export async function addAttachment(client, data) {
9
9
  const { businessTransactionType: btType, businessTransactionResourceId: btId, ...rest } = data;
10
10
  const formData = new FormData();
11
+ if (rest.file)
12
+ formData.append('file', rest.file, rest.fileName ?? 'file');
11
13
  if (rest.attachmentId)
12
14
  formData.append('attachmentId', rest.attachmentId);
13
- if (rest.sourceUrl)
14
- formData.append('sourceUrl', rest.sourceUrl);
15
15
  return client.postMultipart(`/api/v1/${btType}/${btId}/attachments`, formData);
16
16
  }
17
17
  /**
@@ -16,6 +16,12 @@ export async function getBankAccount(client, resourceId) {
16
16
  export async function searchBankRecords(client, accountResourceId, params) {
17
17
  return client.post(`/api/v1/bank-records/${accountResourceId}/search`, params);
18
18
  }
19
+ export async function addBankRecords(client, accountResourceId, records) {
20
+ return client.post(`/api/v1/bank-records/${accountResourceId}`, {
21
+ accountResourceId,
22
+ records,
23
+ });
24
+ }
19
25
  export async function importBankStatement(client, data) {
20
26
  const formData = new FormData();
21
27
  formData.append('businessTransactionType', data.businessTransactionType);
@@ -8,8 +8,8 @@
8
8
  * 0. Normalize & index (integer cents, day offsets, text normalization)
9
9
  * 1. Exact 1:1 hash join (amount + contact + date)
10
10
  * 2. Fuzzy 1:1 greedy assignment (tolerance + scoring)
11
- * 3. N:1 subset-sum (multiple txns → one bank record)
12
- * 4. 1:N reverse subset-sum (multiple bank records → one txn)
11
+ * 3. N:1 subset-sum (multiple txns → one bank record, same-contact constraint)
12
+ * 4. 1:N reverse subset-sum (multiple bank records → one txn, no contact constraint)
13
13
  * 5. N:M two-set matching (within contact groups)
14
14
  *
15
15
  * Performance: <50ms for 500 records × 2000 transactions.
@@ -231,7 +231,7 @@ export function matchBankRecords(input) {
231
231
  let phase3Count = 0;
232
232
  for (const ri of [...recordPool]) {
233
233
  const r = records[ri];
234
- // Gather same-sign candidates within date window
234
+ // Gather same-sign candidates within date window (enriched with contactNorm)
235
235
  const candidates = [];
236
236
  for (const ti of txnPool) {
237
237
  const t = txns[ti];
@@ -239,47 +239,54 @@ export function matchBankRecords(input) {
239
239
  continue;
240
240
  if (Math.abs(r.day - t.day) > dateWindowDays)
241
241
  continue;
242
- candidates.push({ ti, cents: t.cents });
242
+ candidates.push({ ti, cents: t.cents, contactNorm: t.contactNorm });
243
243
  }
244
244
  if (candidates.length < 2)
245
245
  continue;
246
- // DFS operates on absolute values (same-sign filter guarantees all same direction)
247
- // Sort descending by |cents| for DFS pruning
248
- candidates.sort((a, b) => Math.abs(b.cents) - Math.abs(a.cents));
249
- // Build SubsetCandidate array with absolute cents (sign already filtered)
250
- const subCandidates = candidates.map((c, i) => ({
251
- index: i,
252
- cents: Math.abs(c.cents),
253
- }));
254
- // Compute effective tolerance
255
- const isFxRecord = r.isFx || candidates.some(c => txns[c.ti].isFx);
256
- const effectiveTolCents = isFxRecord
257
- ? Math.max(toleranceCents, Math.round(Math.abs(r.cents) * fxTolerancePct))
258
- : toleranceCents;
259
- const subsets = subsetSumDFS(subCandidates, Math.abs(r.cents), effectiveTolCents, maxK, findAll, dfsNodes);
260
- if (subsets.length === 0)
261
- continue;
262
- // Pick best subset: fewest items, then highest composite score
263
- let bestSubset = subsets[0];
264
- let bestScore = -1;
265
- for (const ss of subsets) {
266
- const matchedTxns = ss.indices.map(i => txns[candidates[i].ti]);
267
- const textSig = groupTextScore(r.contact, r.reference, r.description, matchedTxns.map(t => ({ contact: t.contact, reference: t.reference })));
268
- const minDaysDiff = Math.min(...ss.indices.map(i => Math.abs(r.day - txns[candidates[i].ti].day)));
269
- const dateSig = dateScore(minDaysDiff);
270
- const typeSig = typeScore('N:1', ss.indices.length);
271
- let score = compositeScore(textSig, dateSig, typeSig);
272
- if (isFxRecord)
273
- score = Math.max(0, score - FX_PENALTY);
274
- // Prefer: smaller subset, then higher score
275
- const rank = -ss.indices.length * 1000 + score * 100;
276
- if (subsets.length === 1 || rank > bestScore || (rank === bestScore && ss.indices.length < bestSubset.indices.length)) {
277
- bestSubset = ss;
278
- bestScore = rank;
246
+ // Group by contact all txns in a subset must share the same contact
247
+ const contactCandidateGroups = groupByContact(candidates);
248
+ let bestSubset = null;
249
+ let bestGroup = [];
250
+ let bestIsFx = false;
251
+ let bestRank = -Infinity;
252
+ let bestSignals = { text: 0, date: 0, type: 0, score: 0 };
253
+ for (const groupCandidates of contactCandidateGroups.values()) {
254
+ if (groupCandidates.length < 2)
255
+ continue;
256
+ // Sort descending by |cents| for DFS pruning
257
+ groupCandidates.sort((a, b) => Math.abs(b.cents) - Math.abs(a.cents));
258
+ const subCandidates = groupCandidates.map((c, i) => ({
259
+ index: i,
260
+ cents: Math.abs(c.cents),
261
+ }));
262
+ const isFxGroup = r.isFx || groupCandidates.some(c => txns[c.ti].isFx);
263
+ const effectiveTolCents = isFxGroup
264
+ ? Math.max(toleranceCents, Math.round(Math.abs(r.cents) * fxTolerancePct))
265
+ : toleranceCents;
266
+ const subsets = subsetSumDFS(subCandidates, Math.abs(r.cents), effectiveTolCents, maxK, findAll, dfsNodes);
267
+ for (const ss of subsets) {
268
+ const matchedTxns = ss.indices.map(i => txns[groupCandidates[i].ti]);
269
+ const textSig = groupTextScore(r.contact, r.reference, r.description, matchedTxns.map(t => ({ contact: t.contact, reference: t.reference })));
270
+ const minDaysDiff = Math.min(...ss.indices.map(i => Math.abs(r.day - txns[groupCandidates[i].ti].day)));
271
+ const dateSig = dateScore(minDaysDiff);
272
+ const typeSig = typeScore('N:1', ss.indices.length);
273
+ let score = compositeScore(textSig, dateSig, typeSig);
274
+ if (isFxGroup)
275
+ score = Math.max(0, score - FX_PENALTY);
276
+ // Rank: prefer fewer items, then higher score, then deterministic tiebreak
277
+ const rank = -ss.indices.length * 1000 + score * 100;
278
+ if (rank > bestRank || (rank === bestRank && score > bestSignals.score)) {
279
+ bestSubset = ss;
280
+ bestGroup = groupCandidates;
281
+ bestIsFx = isFxGroup;
282
+ bestRank = rank;
283
+ bestSignals = { text: textSig, date: dateSig, type: typeSig, score };
284
+ }
279
285
  }
280
286
  }
281
- // Commit the best subset
282
- const matchedTxnIndices = bestSubset.indices.map(i => candidates[i].ti);
287
+ if (!bestSubset)
288
+ continue;
289
+ const matchedTxnIndices = bestSubset.indices.map(i => bestGroup[i].ti);
283
290
  // Verify none already consumed
284
291
  if (matchedTxnIndices.some(ti => !txnPool.has(ti)))
285
292
  continue;
@@ -287,28 +294,21 @@ export function matchBankRecords(input) {
287
294
  for (const ti of matchedTxnIndices)
288
295
  txnPool.delete(ti);
289
296
  const matchedTxns = matchedTxnIndices.map(ti => txns[ti]);
290
- const textSig = groupTextScore(r.contact, r.reference, r.description, matchedTxns.map(t => ({ contact: t.contact, reference: t.reference })));
291
- const minDaysDiff = Math.min(...matchedTxns.map(t => Math.abs(r.day - t.day)));
292
- const dateSig = dateScore(minDaysDiff);
293
- const typeSig = typeScore('N:1', matchedTxns.length);
294
- let score = compositeScore(textSig, dateSig, typeSig);
295
- if (isFxRecord)
296
- score = Math.max(0, score - FX_PENALTY);
297
- const confidence = scoreToConfidence(score);
297
+ const confidence = scoreToConfidence(bestSignals.score);
298
298
  const txnTotal = round2(matchedTxns.reduce((s, t) => s + t.signedAmount, 0));
299
299
  const proposal = {
300
300
  matchType: 'N:1',
301
301
  confidence,
302
- score: round2Sig(score),
302
+ score: round2Sig(bestSignals.score),
303
303
  bankRecords: [{ id: r.id, amount: r.originalAmount, date: r.date }],
304
304
  transactions: matchedTxns.map(t => ({ id: t.id, amount: t.originalAmount, date: t.date })),
305
305
  bankTotal: r.originalAmount,
306
306
  transactionTotal: txnTotal,
307
307
  variance: round2(r.originalAmount - txnTotal),
308
- signals: { text: round2Sig(textSig), date: round2Sig(dateSig), type: round2Sig(typeSig) },
308
+ signals: { text: round2Sig(bestSignals.text), date: round2Sig(bestSignals.date), type: round2Sig(bestSignals.type) },
309
309
  reason: `${matchedTxns.length} transactions sum to bank record amount (${confidence} confidence)`,
310
310
  };
311
- if (isFxRecord) {
311
+ if (bestIsFx) {
312
312
  proposal.fxVariance = round2(fromCents(Math.abs(r.cents - bestSubset.sum)));
313
313
  }
314
314
  matches.push(proposal);
@@ -321,6 +321,9 @@ export function matchBankRecords(input) {
321
321
  let phase4Count = 0;
322
322
  for (const ti of [...txnPool]) {
323
323
  const t = txns[ti];
324
+ // Gather same-sign candidates within date window
325
+ // Note: NO contact grouping here — bank record payer/payee is metadata,
326
+ // so different bank records can legitimately have different contacts.
324
327
  const candidates = [];
325
328
  for (const ri of recordPool) {
326
329
  const r = records[ri];
@@ -333,7 +336,6 @@ export function matchBankRecords(input) {
333
336
  if (candidates.length < 2)
334
337
  continue;
335
338
  candidates.sort((a, b) => Math.abs(b.cents) - Math.abs(a.cents));
336
- // DFS operates on absolute values (same-sign filter guarantees all same direction)
337
339
  const subCandidates = candidates.map((c, i) => ({
338
340
  index: i,
339
341
  cents: Math.abs(c.cents),
@@ -345,11 +347,25 @@ export function matchBankRecords(input) {
345
347
  const subsets = subsetSumDFS(subCandidates, Math.abs(t.cents), effectiveTolCents, maxK, findAll, dfsNodes);
346
348
  if (subsets.length === 0)
347
349
  continue;
348
- // Pick best: fewest items
350
+ // Pick best: fewer items + higher composite score (matches Phase 3 ranking)
349
351
  let bestSubset = subsets[0];
352
+ let bestRank = -Infinity;
353
+ let bestSignals = { text: 0, date: 0, type: 0, score: 0 };
350
354
  for (const ss of subsets) {
351
- if (ss.indices.length < bestSubset.indices.length)
355
+ const matchedRecords = ss.indices.map(i => records[candidates[i].ri]);
356
+ const textSig = Math.max(...matchedRecords.map(r => crossFieldTextScore(r.contact, r.reference, r.description, t.contact, t.reference)));
357
+ const minDaysDiff = Math.min(...matchedRecords.map(r => Math.abs(r.day - t.day)));
358
+ const dateSig = dateScore(minDaysDiff);
359
+ const typeSig = typeScore('1:N', ss.indices.length);
360
+ let score = compositeScore(textSig, dateSig, typeSig);
361
+ if (isFxTxn)
362
+ score = Math.max(0, score - FX_PENALTY);
363
+ const rank = -ss.indices.length * 1000 + score * 100;
364
+ if (rank > bestRank || (rank === bestRank && score > bestSignals.score)) {
352
365
  bestSubset = ss;
366
+ bestRank = rank;
367
+ bestSignals = { text: textSig, date: dateSig, type: typeSig, score };
368
+ }
353
369
  }
354
370
  const matchedRecordIndices = bestSubset.indices.map(i => candidates[i].ri);
355
371
  if (matchedRecordIndices.some(ri => !recordPool.has(ri)))
@@ -358,25 +374,18 @@ export function matchBankRecords(input) {
358
374
  for (const ri of matchedRecordIndices)
359
375
  recordPool.delete(ri);
360
376
  const matchedRecords = matchedRecordIndices.map(ri => records[ri]);
361
- const textSig = Math.max(...matchedRecords.map(r => crossFieldTextScore(r.contact, r.reference, r.description, t.contact, t.reference)));
362
- const minDaysDiff = Math.min(...matchedRecords.map(r => Math.abs(r.day - t.day)));
363
- const dateSig = dateScore(minDaysDiff);
364
- const typeSig = typeScore('1:N', matchedRecords.length);
365
- let score = compositeScore(textSig, dateSig, typeSig);
366
- if (isFxTxn)
367
- score = Math.max(0, score - FX_PENALTY);
368
- const confidence = scoreToConfidence(score);
377
+ const confidence = scoreToConfidence(bestSignals.score);
369
378
  const bankTotal = round2(matchedRecords.reduce((s, r) => s + r.originalAmount, 0));
370
379
  const proposal = {
371
380
  matchType: '1:N',
372
381
  confidence,
373
- score: round2Sig(score),
382
+ score: round2Sig(bestSignals.score),
374
383
  bankRecords: matchedRecords.map(r => ({ id: r.id, amount: r.originalAmount, date: r.date })),
375
384
  transactions: [{ id: t.id, amount: t.originalAmount, date: t.date }],
376
385
  bankTotal,
377
386
  transactionTotal: t.signedAmount,
378
387
  variance: round2(bankTotal - t.signedAmount),
379
- signals: { text: round2Sig(textSig), date: round2Sig(dateSig), type: round2Sig(typeSig) },
388
+ signals: { text: round2Sig(bestSignals.text), date: round2Sig(bestSignals.date), type: round2Sig(bestSignals.type) },
380
389
  reason: `${matchedRecords.length} bank records sum to transaction amount (${confidence} confidence)`,
381
390
  };
382
391
  if (isFxTxn) {
@@ -569,6 +578,20 @@ export function matchBankRecords(input) {
569
578
  function round2Sig(n) {
570
579
  return Math.round(n * 100) / 100;
571
580
  }
581
+ /** Group candidates by contactNorm for same-contact subset constraint (Phase 3 + 4). */
582
+ function groupByContact(candidates) {
583
+ const groups = new Map();
584
+ for (const c of candidates) {
585
+ const key = c.contactNorm || '';
586
+ let g = groups.get(key);
587
+ if (!g) {
588
+ g = [];
589
+ groups.set(key, g);
590
+ }
591
+ g.push(c);
592
+ }
593
+ return groups;
594
+ }
572
595
  /** Build human-readable reason for a fuzzy 1:1 match. */
573
596
  function buildReason(matchType, confidence, p) {
574
597
  const parts = [];
@@ -52,3 +52,36 @@ export function buildCnFilter(input) {
52
52
  f.contactResourceId = { eq: input.contactResourceId };
53
53
  return Object.keys(f).length > 0 ? f : undefined;
54
54
  }
55
+ /** Build a bank record search filter (shared by CLI + MCP tool). */
56
+ export function buildBankRecordFilter(input) {
57
+ const f = {};
58
+ if (input.status)
59
+ f.status = { eq: input.status };
60
+ if (input.description)
61
+ f.description = { contains: input.description };
62
+ if (input.payer)
63
+ f.extContactName = { contains: input.payer };
64
+ if (input.reference)
65
+ f.extReference = { contains: input.reference };
66
+ // Date range → valueDate expression
67
+ if (input.from || input.to) {
68
+ const d = {};
69
+ if (input.from)
70
+ d.gte = input.from;
71
+ if (input.to)
72
+ d.lte = input.to;
73
+ f.valueDate = d;
74
+ }
75
+ // Amount range → netAmount expression (guard against NaN/Infinity)
76
+ const min = typeof input.amountMin === 'number' && Number.isFinite(input.amountMin) ? input.amountMin : undefined;
77
+ const max = typeof input.amountMax === 'number' && Number.isFinite(input.amountMax) ? input.amountMax : undefined;
78
+ if (min != null || max != null) {
79
+ const a = {};
80
+ if (min != null)
81
+ a.gte = min;
82
+ if (max != null)
83
+ a.lte = max;
84
+ f.netAmount = a;
85
+ }
86
+ return Object.keys(f).length > 0 ? f : undefined;
87
+ }
@@ -5,7 +5,7 @@ import { listInvoices, searchInvoices, getInvoice, createInvoice, updateInvoice,
5
5
  import { listBills, searchBills, getBill, createBill, updateBill, deleteBill, createBillPayment, createScheduledBill, finalizeBill, applyCreditsToBill, } from '../api/bills.js';
6
6
  import { listJournals, searchJournals, getJournal, createJournal, deleteJournal, updateJournal, createScheduledJournal, } from '../api/journals.js';
7
7
  import { generateTrialBalance, generateBalanceSheet, generateProfitAndLoss, generateCashflow, generateArSummary, generateApSummary, generateCashBalance, generateGeneralLedger, } from '../api/reports.js';
8
- import { listBankAccounts, importBankStatement } from '../api/bank.js';
8
+ import { listBankAccounts, addBankRecords, importBankStatement } from '../api/bank.js';
9
9
  import { listItems, searchItems, getItem, createItem, updateItem, deleteItem, } from '../api/items.js';
10
10
  import { listTags, searchTags, createTag, deleteTag, } from '../api/tags.js';
11
11
  import { listCapsuleTypes, listCapsules, searchCapsules, getCapsule, createCapsule, updateCapsule, deleteCapsule, } from '../api/capsules.js';
@@ -29,7 +29,7 @@ import { downloadExport } from '../api/data-exports.js';
29
29
  import { listAttachments, addAttachment, fetchAttachmentTable } from '../api/attachments.js';
30
30
  import { createFromAttachment, searchMagicWorkflows } from '../api/magic.js';
31
31
  import { messageToPdf } from '../api/message-pdf.js';
32
- import { handlePaginationMode, buildCnFilter } from './pagination.js';
32
+ import { handlePaginationMode, buildCnFilter, buildBankRecordFilter } from './pagination.js';
33
33
  // Job blueprints (offline — no API calls)
34
34
  import { generateMonthEndBlueprint } from '../jobs/month-end/blueprint.js';
35
35
  import { generateQuarterEndBlueprint } from '../jobs/quarter-end/blueprint.js';
@@ -1625,9 +1625,17 @@ export const TOOL_DEFINITIONS = [
1625
1625
  },
1626
1626
  {
1627
1627
  name: 'search_bank_records',
1628
- description: 'Search bank records (imported bank transactions) for a specific account.',
1628
+ description: 'Search bank records (imported bank transactions) for a specific account. Supports filtering by status, date range, description, payer/payee, reference, and amount range.',
1629
1629
  params: {
1630
1630
  accountResourceId: { type: 'string', description: 'Bank account resourceId' },
1631
+ status: { type: 'string', enum: ['UNRECONCILED', 'RECONCILED', 'ARCHIVED', 'POSSIBLE_DUPLICATE'], description: 'Filter by reconciliation status' },
1632
+ from: { type: 'string', description: 'Filter from date inclusive (YYYY-MM-DD)' },
1633
+ to: { type: 'string', description: 'Filter to date inclusive (YYYY-MM-DD)' },
1634
+ description: { type: 'string', description: 'Filter by description (contains)' },
1635
+ payer: { type: 'string', description: 'Filter by payer/payee name (contains)' },
1636
+ reference: { type: 'string', description: 'Filter by reference (contains)' },
1637
+ amountMin: { type: 'number', description: 'Filter by minimum amount (inclusive)' },
1638
+ amountMax: { type: 'number', description: 'Filter by maximum amount (inclusive)' },
1631
1639
  ...SEARCH_PARAMS,
1632
1640
  },
1633
1641
  required: ['accountResourceId'],
@@ -1636,8 +1644,9 @@ export const TOOL_DEFINITIONS = [
1636
1644
  execute: async (ctx, input) => {
1637
1645
  const { mode, limit, offset, sortBy, sortOrder } = extractPaginationInput(input);
1638
1646
  return handlePaginationMode(mode, (off, lim) => searchBankRecords(ctx.client, input.accountResourceId, {
1647
+ filter: buildBankRecordFilter(input),
1639
1648
  limit: lim, offset: off,
1640
- sort: { sortBy: [sortBy ?? 'date'], order: (sortOrder ?? 'DESC') },
1649
+ sort: { sortBy: [sortBy ?? 'valueDate'], order: (sortOrder ?? 'DESC') },
1641
1650
  }), limit, offset, 100);
1642
1651
  },
1643
1652
  },
@@ -2038,7 +2047,7 @@ Auto-resolves accounts from chart of accounts. Provide bankAccountName for recip
2038
2047
  },
2039
2048
  notes: { type: 'string', description: 'Journal notes' },
2040
2049
  },
2041
- required: ['startDate', 'repeat', 'schedulerEntries'],
2050
+ required: ['startDate', 'repeat', 'valueDate', 'schedulerEntries'],
2042
2051
  group: 'schedulers',
2043
2052
  readOnly: false,
2044
2053
  execute: async (ctx, input) => createScheduledJournal(ctx.client, {
@@ -2046,6 +2055,7 @@ Auto-resolves accounts from chart of accounts. Provide bankAccountName for recip
2046
2055
  startDate: input.startDate,
2047
2056
  endDate: input.endDate,
2048
2057
  repeat: input.repeat,
2058
+ valueDate: input.valueDate,
2049
2059
  schedulerEntries: input.schedulerEntries,
2050
2060
  reference: input.reference,
2051
2061
  notes: input.notes,
@@ -2081,7 +2091,7 @@ Auto-resolves accounts from chart of accounts. Provide bankAccountName for recip
2081
2091
  },
2082
2092
  tag: { type: 'string', description: 'Tag name' },
2083
2093
  },
2084
- required: ['startDate', 'repeat', 'contactResourceId', 'valueDate', 'dueDate', 'lineItems'],
2094
+ required: ['startDate', 'repeat', 'contactResourceId', 'reference', 'valueDate', 'dueDate', 'lineItems'],
2085
2095
  group: 'schedulers',
2086
2096
  readOnly: false,
2087
2097
  execute: async (ctx, input) => createScheduledInvoice(ctx.client, {
@@ -2130,7 +2140,7 @@ Auto-resolves accounts from chart of accounts. Provide bankAccountName for recip
2130
2140
  },
2131
2141
  tag: { type: 'string', description: 'Tag name' },
2132
2142
  },
2133
- required: ['startDate', 'repeat', 'contactResourceId', 'valueDate', 'dueDate', 'lineItems'],
2143
+ required: ['startDate', 'repeat', 'contactResourceId', 'reference', 'valueDate', 'dueDate', 'lineItems'],
2134
2144
  group: 'schedulers',
2135
2145
  readOnly: false,
2136
2146
  execute: async (ctx, input) => createScheduledBill(ctx.client, {
@@ -2209,6 +2219,36 @@ Auto-resolves accounts from chart of accounts. Provide bankAccountName for recip
2209
2219
  });
2210
2220
  },
2211
2221
  },
2222
+ // ── Bank Records: JSON POST ──────────────────────────────────
2223
+ {
2224
+ name: 'add_bank_records',
2225
+ description: 'Create bank statement entries via JSON POST (1-100 records per call). For CSV/OFX file imports, use import_bank_statement instead.\n\nFields per record:\n- amount (required): positive = cash-in, negative = cash-out\n- transactionDate (required): YYYY-MM-DD\n- description, payerOrPayee, reference: optional strings\n\nReturns {data: {errors: []}} on success. accountResourceId must be a bank-type CoA account (find via list_bank_accounts).',
2226
+ params: {
2227
+ accountResourceId: {
2228
+ type: 'string',
2229
+ description: 'Bank account resourceId (from list_bank_accounts)',
2230
+ },
2231
+ records: {
2232
+ type: 'array',
2233
+ items: {
2234
+ type: 'object',
2235
+ properties: {
2236
+ amount: { type: 'number', description: 'Positive = cash-in, negative = cash-out' },
2237
+ transactionDate: { type: 'string', description: 'Date (YYYY-MM-DD)' },
2238
+ description: { type: 'string', description: 'Description' },
2239
+ payerOrPayee: { type: 'string', description: 'Payer or payee name' },
2240
+ reference: { type: 'string', description: 'Reference' },
2241
+ },
2242
+ required: ['amount', 'transactionDate'],
2243
+ },
2244
+ description: 'Array of 1-100 bank records',
2245
+ },
2246
+ },
2247
+ required: ['accountResourceId', 'records'],
2248
+ group: 'bank',
2249
+ readOnly: false,
2250
+ execute: async (ctx, input) => addBankRecords(ctx.client, input.accountResourceId, input.records),
2251
+ },
2212
2252
  // ── Magic: Create BT from Attachment ──────────────────────────
2213
2253
  {
2214
2254
  name: 'create_bt_from_attachment',
@@ -2299,7 +2339,7 @@ Auto-resolves accounts from chart of accounts. Provide bankAccountName for recip
2299
2339
  },
2300
2340
  {
2301
2341
  name: 'add_attachment',
2302
- description: 'Add an attachment to a business transaction.',
2342
+ description: 'Add an attachment to a business transaction. Provide sourceUrl (downloads and uploads as file) or attachmentId (links existing).',
2303
2343
  params: {
2304
2344
  transactionType: {
2305
2345
  type: 'string',
@@ -2307,8 +2347,8 @@ Auto-resolves accounts from chart of accounts. Provide bankAccountName for recip
2307
2347
  description: 'Transaction type',
2308
2348
  },
2309
2349
  transactionId: { type: 'string', description: 'Transaction resourceId' },
2310
- attachmentId: { type: 'string', description: 'Attachment ID to link' },
2311
- sourceUrl: { type: 'string', description: 'Source file URL (alternative to attachmentId)' },
2350
+ attachmentId: { type: 'string', description: 'Attachment ID to link (alternative to sourceUrl)' },
2351
+ sourceUrl: { type: 'string', description: 'Source file URL downloaded and uploaded as multipart file' },
2312
2352
  },
2313
2353
  required: ['transactionType', 'transactionId'],
2314
2354
  group: 'attachments',
@@ -2317,11 +2357,28 @@ Auto-resolves accounts from chart of accounts. Provide bankAccountName for recip
2317
2357
  if (!input.attachmentId && !input.sourceUrl) {
2318
2358
  throw new Error('Provide attachmentId or sourceUrl — one is required.');
2319
2359
  }
2360
+ let file;
2361
+ let fileName;
2362
+ if (input.sourceUrl) {
2363
+ const res = await fetch(input.sourceUrl);
2364
+ if (!res.ok)
2365
+ throw new Error(`Failed to download ${input.sourceUrl}: ${res.status}`);
2366
+ const buffer = await res.arrayBuffer();
2367
+ const contentType = res.headers.get('content-type') ?? 'application/octet-stream';
2368
+ file = new Blob([buffer], { type: contentType });
2369
+ try {
2370
+ fileName = new URL(input.sourceUrl).pathname.split('/').pop() || 'attachment';
2371
+ }
2372
+ catch {
2373
+ fileName = 'attachment';
2374
+ }
2375
+ }
2320
2376
  return addAttachment(ctx.client, {
2321
2377
  businessTransactionType: input.transactionType,
2322
2378
  businessTransactionResourceId: input.transactionId,
2379
+ file,
2380
+ fileName,
2323
2381
  attachmentId: input.attachmentId,
2324
- sourceUrl: input.sourceUrl,
2325
2382
  });
2326
2383
  },
2327
2384
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jaz-clio",
3
- "version": "4.20.0",
3
+ "version": "4.21.2",
4
4
  "description": "Clio — Command Line Interface Orchestrator for Jaz AI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -56,9 +56,11 @@
56
56
  "update-notifier": "^7.3.1"
57
57
  },
58
58
  "devDependencies": {
59
+ "@anthropic-ai/mcpb": "^2.1.2",
59
60
  "@types/adm-zip": "^0.5.6",
60
61
  "@types/node": "^22.10.1",
61
62
  "@types/prompts": "^2.4.9",
63
+ "esbuild": "^0.27.3",
62
64
  "pino-pretty": "^13.1.3",
63
65
  "typescript": "^5.7.2",
64
66
  "vitest": "^4.0.18"