jaz-clio 4.3.0 → 4.5.0

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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: jaz-api
3
- version: 4.3.0
3
+ version: 4.5.0
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.
@@ -211,8 +211,12 @@ Step 3: For each draft where ready = true:
211
211
 
212
212
  81. **`--account` bulk patches line items** — when used with `clio bills draft finalize`, `--account` resolves the name to a UUID then sets `accountResourceId` on EVERY line item where it's currently null. Existing accounts are NOT overwritten. Same for `--tax-profile`. `--lines` takes priority (full replacement).
213
213
  82. **`--dry-run` validates without modifying** — returns the same validation structure as `draft list` (per-field status/hint), so agents can preview what would happen before committing. No API write occurs.
214
- 83. **Finalization is a single PUT** — `updateBill()` with `saveAsDraft: false` transitions DRAFT → UNPAID (per Rule 67) and updates all fields in one call. No delete-and-recreate.
214
+ 83. **Finalization is a single PUT** — `updateBill()` with `saveAsDraft: false` transitions DRAFT → UNPAID (per Rule 67) and updates all fields in one call. No delete-and-recreate. The CLI handles all field normalization automatically (date format, line item sanitization, account field name mapping).
215
215
  84. **Draft list attachment count** — `draft list` includes `attachmentCount` per draft (from `GET /bills/:id/attachments`). Use `draft attachments <id>` for full details including `fileUrl` download links.
216
+ 85. **PUT body requires `resourceId`** — The UpdateBill PUT endpoint requires `resourceId` in the body (in addition to the URL path). Dates must be `YYYY-MM-DD` (not ISO with time). `taxInclusion` is boolean (`true`/`false`), not string. Line items must use `accountResourceId` (not `organizationAccountResourceId` from GET).
217
+ 86. **GET→PUT field asymmetry** — GET returns `organizationAccountResourceId` on line items; PUT requires `accountResourceId`. GET returns dates as `2026-02-27T00:00:00Z`; PUT requires `2026-02-27`. GET returns `taxProfile: { resourceId }` object; PUT requires `taxProfileResourceId` string. The CLI `draft finalize` command normalizes all of these automatically.
218
+ 87. **Magic workflow status may be null immediately after creation** — The `POST /magic/workflows/search` endpoint may return a workflow with `status: null` right after `POST /magic/create-from-attachment`. Allow 2-3 seconds before polling, or default to `SUBMITTED`. The CLI `magic status` command defaults null status to `SUBMITTED`.
219
+ 88. **Finalized invoices/bills need `accountResourceId` on all line items** — When `saveAsDraft: false` (or using `--finalize`), every `lineItems[i].accountResourceId` must be set. Omitting it causes 422: "lineItems[0].accountResourceId is required if [saveAsDraft] is false". The CLI validates this pre-flight.
216
220
 
217
221
  #### DRY Extension Pattern
218
222
 
@@ -985,6 +985,12 @@ Content-Type: application/json
985
985
  - Tax amounts and profiles
986
986
  - Document reference numbers, dates, currency
987
987
 
988
+ **Encrypted PDFs:** Magic cannot process password-protected PDFs. The CLI auto-detects and decrypts before upload:
989
+ - Embed password in filename: `receipt__pw__s3cRetP@ss.pdf` → decrypts with password `s3cRetP@ss`, uploads as `receipt.pdf`
990
+ - `__pw__` delimiter is case-insensitive; password is case-sensitive
991
+ - Requires `qpdf` installed (`brew install qpdf`)
992
+ - If no password in filename, CLI prompts interactively (or errors in `--json` mode with actionable rename instructions)
993
+
988
994
  **Key gotchas:**
989
995
  - `sourceFile` is the field name (NOT `file`) — same pattern as bank statement endpoint
990
996
  - `EXPENSE` returns 422 — use one of the 4 valid types above
@@ -1056,6 +1062,41 @@ Content-Type: application/json
1056
1062
  3. When `COMPLETED` → read `businessTransactionDetails.businessTransactionResourceId`
1057
1063
  4. Use the BT resource ID with `GET /invoices/:id`, `GET /bills/:id`, `GET /customer-credit-notes/:id`, or `GET /supplier-credit-notes/:id`
1058
1064
 
1065
+ ### CLI: clio magic split — Merged PDF Splitting
1066
+
1067
+ Splits a merged PDF containing multiple documents (invoices, bills, credit notes) into individual files and uploads each to Magic. Uses structural PDF signals (bookmarks, page labels) + text heuristics (keywords, "Page 1 of N" patterns) for boundary detection. **No AI tokens used.**
1068
+
1069
+ ```bash
1070
+ # Auto-detect boundaries + upload
1071
+ clio magic split --file merged.pdf --type bill
1072
+
1073
+ # Manual page ranges (for scanned PDFs or override)
1074
+ clio magic split --file merged.pdf --type bill --pages "1-3,4-6,7-9"
1075
+
1076
+ # Dry-run: detect boundaries only (no qpdf needed)
1077
+ clio magic split --file merged.pdf --type bill --dry-run
1078
+
1079
+ # JSON output (for agents)
1080
+ clio magic split --file merged.pdf --type bill --dry-run --json
1081
+ ```
1082
+
1083
+ **Detection signals (score-based, threshold >= 50):**
1084
+ - outline-bookmark (+80): PDF bookmark points to this page
1085
+ - page-label-reset (+70): PDF page label restarts at "1"
1086
+ - keyword in header (+40): Document keyword (INVOICE, BILL, etc.) in upper 40%
1087
+ - page-one-of (+35): "Page 1 of N" pattern
1088
+ - keyword-large (+25): Large font (>18pt) keyword bonus
1089
+ - doc-ref (+20): Document reference (INV-001, SO-2024-100, etc.)
1090
+ - continuation (-60): "Page N>1 of M" anti-signal
1091
+ - continuation-text (-40): "Continued" anti-signal
1092
+
1093
+ **Edge cases:**
1094
+ - Scanned PDFs (no extractable text): warns and requires `--pages` manual override
1095
+ - Mixed scanned+digital: low confidence on scanned portions triggers confirmation prompt
1096
+ - Single document detected: suggests `clio magic create` instead
1097
+ - Encrypted PDFs: same `__pw__` pattern as `magic create`
1098
+ - Requires `qpdf` for splitting (not needed for `--dry-run` auto-detect mode)
1099
+
1059
1100
  ---
1060
1101
 
1061
1102
  ## 15. Bank Records
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: jaz-conversion
3
- version: 4.3.0
3
+ version: 4.5.0
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.3.0
3
+ version: 4.5.0
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.
@@ -97,8 +97,10 @@ clio jobs document-collection ingest --source "https://www.dropbox.com/..." --ti
97
97
  clio jobs document-collection ingest --source ./scans/ --type invoice [--json]
98
98
  clio jobs document-collection ingest --source ./scans/ --type credit-note-customer [--json]
99
99
 
100
- # Scan + upload (decrypt encrypted PDFs with password)
101
- clio jobs document-collection ingest --source ./bank-docs/ --upload --bank-account "DBS Checking" --pdf-password "mypass" --json
100
+ # Scan + upload (encrypted PDFs with password embedded in filename)
101
+ clio jobs document-collection ingest --source ./bank-docs/ --upload --bank-account "DBS Checking" --json
102
+ # To decrypt: rename encrypted PDF to: receipt__pw__actualPassword.pdf
103
+ # The __pw__ delimiter is case-insensitive; the password itself is case-sensitive.
102
104
  ```
103
105
 
104
106
  ### Options
@@ -109,12 +111,21 @@ clio jobs document-collection ingest --source ./bank-docs/ --upload --bank-accou
109
111
  | `--type <type>` | Force all files to: `invoice`, `bill`, `credit-note-customer`, `credit-note-supplier`, or `bank-statement` |
110
112
  | `--upload` | Upload classified files to Jaz after scanning (requires auth) |
111
113
  | `--bank-account <name-or-id>` | Bank account name or resourceId (required for bank statements) |
112
- | `--pdf-password <password>` | Password for encrypted PDFs — same password applied to all. Requires `qpdf` installed (`brew install qpdf`) |
113
114
  | `--api-key <key>` | API key for upload (or use `JAZ_API_KEY` env var) |
114
115
  | `--timeout <ms>` | Download timeout in milliseconds (default: 30000 for files, 120000 for folders) |
115
116
  | `--currency <code>` | Functional/reporting currency label |
116
117
  | `--json` | Structured JSON output with absolute file paths |
117
118
 
119
+ ### Encrypted PDF Passwords
120
+
121
+ Embed the password in the filename using the `__pw__` pattern:
122
+ ```
123
+ receipt__pw__s3cRetP@ss.pdf → password: "s3cRetP@ss", display name: "receipt.pdf"
124
+ ```
125
+ - `__pw__` is case-insensitive (`__PW__`, `__Pw__`, etc.)
126
+ - The password after `__pw__` is case-sensitive
127
+ - Requires `qpdf` installed (`brew install qpdf`)
128
+
118
129
  ### JSON Output
119
130
 
120
131
  The `--json` output includes absolute file paths, classification, and size for each file. The AI agent uses these paths to upload via the api skill.
@@ -171,7 +182,7 @@ sudo apt install qpdf # Ubuntu/Debian
171
182
  choco install qpdf # Windows
172
183
  ```
173
184
 
174
- When `--upload` is used with `--pdf-password`, encrypted PDFs are decrypted to a temp file, uploaded, then cleaned up. The same password is applied to all encrypted files in the batch.
185
+ Passwords are embedded in the filename using the `__pw__` pattern: `receipt__pw__myPass.pdf`. During upload, encrypted PDFs with a filename password are decrypted to a temp file, uploaded, then cleaned up.
175
186
 
176
187
  ### Error Handling (JSON mode)
177
188
 
@@ -180,7 +191,7 @@ If encrypted PDFs are found during `--upload` without the required dependencies,
180
191
  | Error Code | Condition | Action |
181
192
  |------------|-----------|--------|
182
193
  | `ENCRYPTED_PDF_NO_QPDF` | qpdf not installed | Install qpdf, then retry |
183
- | `ENCRYPTED_PDF_NO_PASSWORD` | No `--pdf-password` provided | Retry with `--pdf-password <password>` |
194
+ | `ENCRYPTED_PDF_NO_PASSWORD` | Encrypted PDF without `__pw__` in filename | Rename file to embed password: `name__pw__password.pdf` |
184
195
 
185
196
  ## Phases (Blueprint)
186
197
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: jaz-recipes
3
- version: 4.3.0
3
+ version: 4.5.0
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 10 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.
@@ -51,7 +51,7 @@ export function apiAction(fn) {
51
51
  process.exit(3);
52
52
  }
53
53
  if (err instanceof JazApiError) {
54
- console.error(chalk.red(`API Error (${err.status}): ${err.endpoint}`));
54
+ console.error(chalk.red(`API Error (${err.status}): ${err.message}`));
55
55
  process.exit(2);
56
56
  }
57
57
  console.error(chalk.red(`Error: ${err.message}`));
@@ -5,7 +5,7 @@ import { apiAction } from './api-action.js';
5
5
  import { resolveContactFlag, resolveAccountFlag, resolveTaxProfileFlag } from './resolve.js';
6
6
  import { parsePositiveInt, parseNonNegativeInt, parseMoney, parseRate, parseLineItems, readBodyInput, requireFields } from './parsers.js';
7
7
  import { paginatedFetch, paginatedJson, displaySlice } from './pagination.js';
8
- import { BILL_REQUIRED_FIELDS, buildDraftReport, formatDraftTable, addDraftFinalizeOptions, mergeDraftFlags, validateDraft, buildValidation, } from './draft-helpers.js';
8
+ import { BILL_REQUIRED_FIELDS, buildDraftReport, formatDraftTable, addDraftFinalizeOptions, mergeDraftFlags, validateDraft, buildValidation, normalizeDate, sanitizeLineItem, } from './draft-helpers.js';
9
9
  export function registerBillsCommand(program) {
10
10
  const bills = program
11
11
  .command('bills')
@@ -448,12 +448,40 @@ export function registerBillsCommand(program) {
448
448
  else {
449
449
  updateData = mergeDraftFlags(bill, resolvedOpts);
450
450
  }
451
+ // Track which fields the user explicitly changed (before we build the full PUT body)
452
+ const userChangedFields = Object.keys(updateData);
451
453
  // 4. Validate after merge — apply updates to a virtual copy
452
454
  const merged = { ...bill, ...updateData };
453
455
  if (updateData.lineItems) {
454
456
  merged.lineItems = updateData.lineItems;
455
457
  }
456
458
  const { missingFields, missingCount, ready } = validateDraft(merged, BILL_REQUIRED_FIELDS);
459
+ // 4b. Build a full PUT-compatible body from merged state.
460
+ // The API PUT requires: reference, contactResourceId, dates (YYYY-MM-DD),
461
+ // and line items in create-request format (not GET response shape).
462
+ if (!body) {
463
+ const fullBody = {
464
+ reference: merged.reference || undefined,
465
+ contactResourceId: merged.contactResourceId,
466
+ valueDate: normalizeDate(updateData.valueDate) || normalizeDate(bill.valueDate),
467
+ dueDate: normalizeDate(updateData.dueDate) || normalizeDate(bill.dueDate),
468
+ };
469
+ // Sanitize line items: strip response-only fields, normalize account field name
470
+ const rawItems = (updateData.lineItems ?? bill.lineItems ?? []); // eslint-disable-line @typescript-eslint/no-explicit-any
471
+ fullBody.lineItems = rawItems.map((li) => sanitizeLineItem(li)); // eslint-disable-line @typescript-eslint/no-explicit-any
472
+ // Carry over tax settings
473
+ if (updateData.isTaxVatApplicable != null || bill.isTaxVATApplicable != null) {
474
+ fullBody.isTaxVATApplicable = updateData.isTaxVatApplicable ?? bill.isTaxVATApplicable;
475
+ }
476
+ if (updateData.taxInclusion != null || bill.taxInclusion != null) {
477
+ fullBody.taxInclusion = updateData.taxInclusion ?? bill.taxInclusion;
478
+ }
479
+ if (updateData.notes)
480
+ fullBody.notes = updateData.notes;
481
+ if (updateData.tag)
482
+ fullBody.tag = updateData.tag;
483
+ updateData = fullBody;
484
+ }
457
485
  // 5. Dry run: just report validation
458
486
  if (opts.dryRun) {
459
487
  const validation = buildValidation(merged, BILL_REQUIRED_FIELDS);
@@ -508,10 +536,12 @@ export function registerBillsCommand(program) {
508
536
  process.exit(1);
509
537
  }
510
538
  // 7. Finalize: PUT with saveAsDraft=false
511
- const finalRes = await finalizeBill(client, resourceId, updateData);
512
- const updated = finalRes.data;
513
- // Compute which fields were updated
514
- const fieldsUpdated = Object.keys(updateData).filter((k) => k !== 'saveAsDraft');
539
+ await finalizeBill(client, resourceId, updateData);
540
+ // 8. GET the finalized bill to confirm status (PUT response may not include status)
541
+ const verifyRes = await getBill(client, resourceId);
542
+ const updated = verifyRes.data;
543
+ // Report which fields the user explicitly changed (not the full PUT body)
544
+ const fieldsUpdated = userChangedFields.filter((k) => k !== 'saveAsDraft' && k !== 'resourceId');
515
545
  if (opts.json) {
516
546
  console.log(JSON.stringify({
517
547
  finalized: true,
@@ -1,9 +1,11 @@
1
1
  import chalk from 'chalk';
2
- import { listCustomerCreditNotes, getCustomerCreditNote, searchCustomerCreditNotes, createCustomerCreditNote, updateCustomerCreditNote, deleteCustomerCreditNote, createCustomerCreditNoteRefund, listCustomerCreditNoteRefunds, } from '../core/api/customer-cn.js';
2
+ import { listCustomerCreditNotes, getCustomerCreditNote, searchCustomerCreditNotes, createCustomerCreditNote, updateCustomerCreditNote, deleteCustomerCreditNote, createCustomerCreditNoteRefund, listCustomerCreditNoteRefunds, finalizeCustomerCreditNote, } from '../core/api/customer-cn.js';
3
+ import { listAttachments } from '../core/api/attachments.js';
3
4
  import { apiAction } from './api-action.js';
4
- import { resolveContactFlag, resolveAccountFlag } from './resolve.js';
5
+ import { resolveContactFlag, resolveAccountFlag, resolveTaxProfileFlag } from './resolve.js';
5
6
  import { parsePositiveInt, parseNonNegativeInt, parseMoney, parseRate, parseLineItems, readBodyInput, requireFields } from './parsers.js';
6
7
  import { paginatedFetch, paginatedJson, displaySlice } from './pagination.js';
8
+ import { CREDIT_NOTE_REQUIRED_FIELDS, buildDraftReport, formatDraftTable, addDraftFinalizeOptions, mergeDraftFlags, validateDraft, buildValidation, normalizeDate, sanitizeLineItem, } from './draft-helpers.js';
7
9
  export function registerCustomerCreditNotesCommand(program) {
8
10
  const ccn = program
9
11
  .command('customer-credit-notes')
@@ -301,4 +303,211 @@ export function registerCustomerCreditNotesCommand(program) {
301
303
  }
302
304
  }
303
305
  })(opts));
306
+ // ── clio customer-credit-notes draft ──────────────────────────
307
+ const draft = ccn
308
+ .command('draft')
309
+ .description('Manage draft customer credit notes (inspect, finalize, attachments)');
310
+ // ── clio customer-credit-notes draft list ─────────────────────
311
+ draft
312
+ .command('list')
313
+ .description('List all draft customer credit notes with per-field validation status')
314
+ .option('--ids <ids>', 'Comma-separated IDs to inspect (instead of all drafts)')
315
+ .option('--max-rows <n>', 'Max drafts to fetch (default 10000)', parsePositiveInt)
316
+ .option('--api-key <key>', 'API key (overrides stored/env)')
317
+ .option('--json', 'Output as JSON')
318
+ .action(apiAction(async (client, opts) => {
319
+ let items; // eslint-disable-line @typescript-eslint/no-explicit-any
320
+ if (opts.ids) {
321
+ const ids = opts.ids.split(',').map((s) => s.trim()).filter(Boolean);
322
+ items = await Promise.all(ids.map(async (id) => {
323
+ const res = await getCustomerCreditNote(client, id);
324
+ return res.data;
325
+ }));
326
+ items = items.filter((cn) => cn.status === 'DRAFT');
327
+ }
328
+ else {
329
+ const result = await paginatedFetch({ all: true, json: true, maxRows: opts.maxRows }, ({ limit, offset }) => searchCustomerCreditNotes(client, {
330
+ filter: { status: { eq: 'DRAFT' } },
331
+ limit,
332
+ offset,
333
+ sort: { sortBy: ['valueDate'], order: 'DESC' },
334
+ }), { label: 'Fetching draft customer credit notes' });
335
+ items = result.data;
336
+ }
337
+ const BATCH_SIZE = 5;
338
+ const reports = [];
339
+ for (let i = 0; i < items.length; i += BATCH_SIZE) {
340
+ const batch = items.slice(i, i + BATCH_SIZE);
341
+ const batchReports = await Promise.all(batch.map(async (cn) => {
342
+ let attachmentCount = 0;
343
+ try {
344
+ const attRes = await listAttachments(client, 'customer-credit-notes', cn.resourceId);
345
+ attachmentCount = Array.isArray(attRes.data) ? attRes.data.length : 0;
346
+ }
347
+ catch { /* Attachment listing may fail — don't block the report */ }
348
+ return buildDraftReport(cn, CREDIT_NOTE_REQUIRED_FIELDS, attachmentCount);
349
+ }));
350
+ reports.push(...batchReports);
351
+ }
352
+ if (opts.json) {
353
+ const readyCount = reports.filter((r) => r.ready).length;
354
+ console.log(JSON.stringify({
355
+ totalDrafts: reports.length,
356
+ readyCount,
357
+ needsAttentionCount: reports.length - readyCount,
358
+ drafts: reports,
359
+ }, null, 2));
360
+ }
361
+ else {
362
+ formatDraftTable('Customer Credit Notes', reports);
363
+ }
364
+ }));
365
+ // ── clio customer-credit-notes draft finalize ─────────────────
366
+ addDraftFinalizeOptions(draft
367
+ .command('finalize <resourceId>')
368
+ .description('Fill missing fields and convert a draft customer credit note to APPROVED')).action((resourceId, opts) => apiAction(async (client) => {
369
+ const body = readBodyInput(opts);
370
+ // 1. GET CURRENT DRAFT
371
+ const res = await getCustomerCreditNote(client, resourceId);
372
+ const cn = res.data;
373
+ if (cn.status !== 'DRAFT') {
374
+ const msg = `Customer credit note ${resourceId} is ${cn.status}, not DRAFT.`;
375
+ if (opts.json) {
376
+ console.log(JSON.stringify({ finalized: false, error: msg }));
377
+ }
378
+ else {
379
+ console.error(chalk.red(msg));
380
+ }
381
+ process.exit(1);
382
+ }
383
+ // 2. RESOLVE NAMES → UUIDs
384
+ const resolvedOpts = { ...opts }; // eslint-disable-line @typescript-eslint/no-explicit-any
385
+ if (opts.contact) {
386
+ const resolved = await resolveContactFlag(client, opts.contact, { silent: opts.json });
387
+ resolvedOpts.contact = resolved.resourceId;
388
+ }
389
+ if (opts.account) {
390
+ const resolved = await resolveAccountFlag(client, opts.account, { filter: 'line-item', silent: opts.json });
391
+ resolvedOpts.account = resolved.resourceId;
392
+ }
393
+ if (opts.taxProfile) {
394
+ const resolved = await resolveTaxProfileFlag(client, opts.taxProfile, { silent: opts.json });
395
+ resolvedOpts.taxProfile = resolved.resourceId;
396
+ }
397
+ // 3. MERGE FLAGS WITH EXISTING VALUES
398
+ let updateData;
399
+ if (body) {
400
+ updateData = body;
401
+ }
402
+ else {
403
+ updateData = mergeDraftFlags(cn, resolvedOpts);
404
+ }
405
+ const userChangedFields = Object.keys(updateData);
406
+ // 4. VALIDATE AFTER MERGE
407
+ const merged = { ...cn, ...updateData };
408
+ if (updateData.lineItems) {
409
+ merged.lineItems = updateData.lineItems;
410
+ }
411
+ const { missingFields, missingCount, ready } = validateDraft(merged, CREDIT_NOTE_REQUIRED_FIELDS);
412
+ // 4b. BUILD FULL PUT-COMPATIBLE BODY (no dueDate for credit notes)
413
+ if (!body) {
414
+ const fullBody = {
415
+ reference: merged.reference || undefined,
416
+ contactResourceId: merged.contactResourceId,
417
+ valueDate: normalizeDate(updateData.valueDate) || normalizeDate(cn.valueDate),
418
+ };
419
+ const rawItems = (updateData.lineItems ?? cn.lineItems ?? []); // eslint-disable-line @typescript-eslint/no-explicit-any
420
+ fullBody.lineItems = rawItems.map((li) => sanitizeLineItem(li)); // eslint-disable-line @typescript-eslint/no-explicit-any
421
+ if (updateData.isTaxVatApplicable != null || cn.isTaxVATApplicable != null) {
422
+ fullBody.isTaxVATApplicable = updateData.isTaxVatApplicable ?? cn.isTaxVATApplicable;
423
+ }
424
+ if (updateData.taxInclusion != null || cn.taxInclusion != null) {
425
+ fullBody.taxInclusion = updateData.taxInclusion ?? cn.taxInclusion;
426
+ }
427
+ if (updateData.notes)
428
+ fullBody.notes = updateData.notes;
429
+ if (updateData.tag)
430
+ fullBody.tag = updateData.tag;
431
+ updateData = fullBody;
432
+ }
433
+ // 5. DRY RUN
434
+ if (opts.dryRun) {
435
+ const validation = buildValidation(merged, CREDIT_NOTE_REQUIRED_FIELDS);
436
+ if (opts.json) {
437
+ console.log(JSON.stringify({ finalized: false, dryRun: true, resourceId, reference: merged.reference || null, ready, missingCount, missingFields, validation }, null, 2));
438
+ }
439
+ else {
440
+ if (ready) {
441
+ console.log(chalk.green(`✓ ${merged.reference || resourceId} is ready to finalize.`));
442
+ }
443
+ else {
444
+ console.log(chalk.yellow(`✗ ${merged.reference || resourceId} — ${missingCount} issue${missingCount > 1 ? 's' : ''} remaining:`));
445
+ for (const f of missingFields) {
446
+ const spec = CREDIT_NOTE_REQUIRED_FIELDS.find((s) => f === s.field || f.endsWith(`.${s.field}`));
447
+ console.log(` ${f}: ${chalk.red('MISSING')} — ${spec?.hint ?? ''}`);
448
+ }
449
+ }
450
+ }
451
+ return;
452
+ }
453
+ // 6. VALIDATION FAILURE
454
+ if (!ready) {
455
+ const validation = buildValidation(merged, CREDIT_NOTE_REQUIRED_FIELDS);
456
+ if (opts.json) {
457
+ console.log(JSON.stringify({ finalized: false, resourceId, reference: merged.reference || null, ready: false, missingCount, missingFields, validation }, null, 2));
458
+ }
459
+ else {
460
+ console.error(chalk.red(`\n✗ Cannot finalize ${merged.reference || resourceId} — ${missingCount} issue${missingCount > 1 ? 's' : ''} remaining:\n`));
461
+ for (const f of missingFields) {
462
+ const spec = CREDIT_NOTE_REQUIRED_FIELDS.find((s) => f === s.field || f.endsWith(`.${s.field}`));
463
+ console.error(` ${f}: ${chalk.red('MISSING')} — ${spec?.hint ?? ''}`);
464
+ }
465
+ console.error(chalk.dim(`\n Fix the issues and retry:\n clio customer-credit-notes draft finalize ${resourceId} ...\n`));
466
+ }
467
+ process.exit(1);
468
+ }
469
+ // 7. FINALIZE
470
+ await finalizeCustomerCreditNote(client, resourceId, updateData);
471
+ // 8. VERIFY
472
+ const verifyRes = await getCustomerCreditNote(client, resourceId);
473
+ const updated = verifyRes.data;
474
+ const fieldsUpdated = userChangedFields.filter((k) => k !== 'saveAsDraft' && k !== 'resourceId');
475
+ if (opts.json) {
476
+ console.log(JSON.stringify({ finalized: true, resourceId: updated.resourceId, reference: updated.reference || null, status: updated.status, fieldsUpdated }, null, 2));
477
+ }
478
+ else {
479
+ console.log(chalk.green(`\n✓ Customer credit note finalized: ${updated.reference || updated.resourceId}`));
480
+ console.log(chalk.bold(' Status:'), updated.status);
481
+ if (fieldsUpdated.length > 0) {
482
+ console.log(chalk.bold(' Updated:'), fieldsUpdated.join(', '));
483
+ }
484
+ }
485
+ })(opts));
486
+ // ── clio customer-credit-notes draft attachments ──────────────
487
+ draft
488
+ .command('attachments <resourceId>')
489
+ .description('List attachments for a customer credit note (URLs for agent inspection)')
490
+ .option('--api-key <key>', 'API key (overrides stored/env)')
491
+ .option('--json', 'Output as JSON')
492
+ .action((resourceId, opts) => apiAction(async (client) => {
493
+ const cnRes = await getCustomerCreditNote(client, resourceId);
494
+ const cn = cnRes.data;
495
+ const attRes = await listAttachments(client, 'customer-credit-notes', resourceId);
496
+ const attachments = Array.isArray(attRes.data) ? attRes.data : [];
497
+ if (opts.json) {
498
+ console.log(JSON.stringify({ creditNoteResourceId: resourceId, creditNoteReference: cn.reference || null, attachments }, null, 2));
499
+ }
500
+ else {
501
+ if (attachments.length === 0) {
502
+ console.log(chalk.yellow(`No attachments for customer credit note ${cn.reference || resourceId}.`));
503
+ return;
504
+ }
505
+ console.log(chalk.bold(`Attachments for ${cn.reference || resourceId}:\n`));
506
+ for (const att of attachments) {
507
+ console.log(` ${chalk.cyan(att.resourceId)} ${att.fileName || '(unnamed)'}`);
508
+ if (att.fileUrl)
509
+ console.log(` ${chalk.dim(att.fileUrl)}`);
510
+ }
511
+ }
512
+ })(opts));
304
513
  }