noxctl 0.4.1 → 0.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.
package/README.md CHANGED
@@ -261,6 +261,7 @@ Every operation is available both as a CLI command and as an MCP tool. The CLI i
261
261
  | `noxctl invoices create --customer <number> --input <file>` | `fortnox_create_invoice` | Create an invoice (mutation) |
262
262
  | `noxctl invoices update <docNumber> --input <file>` | `fortnox_update_invoice` | Update an invoice that has not been bookkeept (mutation) |
263
263
  | `noxctl invoices send <docNumber> [--method email\|print\|einvoice] [--subject <s>] [--body <s>] [--bcc <email>]` | `fortnox_send_invoice` | Send via email (default), print, or e-invoice (mutation) |
264
+ | `noxctl invoices pdf <docNumber> [--file <path>\|-] [--mark-sent]` | `fortnox_invoice_pdf` | Download the invoice PDF (via `/preview`, no side effect). `--mark-sent` also flags it as sent afterwards (mutation) |
264
265
  | `noxctl invoices bookkeep <docNumber>` | `fortnox_bookkeep_invoice` | Bookkeep an invoice (mutation) |
265
266
  | `noxctl invoices credit <docNumber>` | `fortnox_credit_invoice` | Credit an invoice (mutation) |
266
267
 
@@ -480,6 +481,8 @@ CLI:
480
481
  noxctl invoices send 1001 # prompts: "Send invoice 1001 via email. Continue? [y/N]"
481
482
  noxctl invoices send 1001 --yes # skip prompt (scripting/AI)
482
483
  noxctl invoices send 1001 --dry-run # preview without sending
484
+ noxctl invoices pdf 1001 # read-only, no prompt
485
+ noxctl invoices pdf 1001 --mark-sent # prompts: also flags the invoice as sent
483
486
  noxctl customers update 42 --input customer.json # prompts for confirmation
484
487
  noxctl vouchers create --input voucher.json --dry-run # preview payload
485
488
  ```
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAcA,OAAO,EAOL,KAAK,eAAe,EACrB,MAAM,eAAe,CAAC;AAwHvB,wBAAgB,sBAAsB,IAAI,eAAe,CAExD"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAcA,OAAO,EAOL,KAAK,eAAe,EACrB,MAAM,eAAe,CAAC;AAmIvB,wBAAgB,sBAAsB,IAAI,eAAe,CAExD"}
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command, Option } from 'commander';
3
- import { readFileSync } from 'node:fs';
3
+ import { readFileSync, writeFileSync } from 'node:fs';
4
4
  import { createInterface } from 'node:readline/promises';
5
5
  import { isJsonMode, outputList, outputDetail, outputConfirmation, formatTaxReport, formatFinancialReport, errorEnvelope, } from './formatter.js';
6
6
  import { readActivePointer, readActivePointerOutcome, writeActivePointer, deleteActivePointer, readProfileIndex, resolveProfile, } from './profiles.js';
@@ -9,10 +9,20 @@ import { applyPeriod } from './date-periods.js';
9
9
  import { DEFAULT_PROFILE, InvalidProfileNameError } from './profile-name.js';
10
10
  import { invoiceListColumns, invoiceDetailColumns, invoiceConfirmColumns, customerListColumns, customerDetailColumns, voucherListColumns, voucherDetailColumns, voucherRowColumns, accountListColumns, companyDetailColumns, articleListColumns, articleDetailColumns, supplierListColumns, supplierDetailColumns, supplierInvoiceListColumns, supplierInvoiceDetailColumns, supplierInvoiceConfirmColumns, invoicePaymentListColumns, invoicePaymentDetailColumns, supplierInvoicePaymentListColumns, supplierInvoicePaymentDetailColumns, offerListColumns, offerDetailColumns, orderListColumns, orderDetailColumns, projectListColumns, projectDetailColumns, costCenterListColumns, costCenterDetailColumns, taxReductionListColumns, taxReductionDetailColumns, priceListListColumns, priceListDetailColumns, priceListColumns, priceDetailColumns, financialYearListColumns, financialYearDetailColumns, lockedPeriodDetailColumns, contractListColumns, contractDetailColumns, topCustomerColumns, monthlyRevenueColumns, voucherAttachmentColumns, employeeListColumns, employeeDetailColumns, salaryTransactionListColumns, salaryTransactionDetailColumns, attendanceTransactionListColumns, attendanceTransactionDetailColumns, absenceTransactionListColumns, absenceTransactionDetailColumns, scheduleTimeDetailColumns, } from './views.js';
11
11
  const program = new Command();
12
+ // A closed downstream pipe (`noxctl ... | head`, or a reader that exits early)
13
+ // makes stdout emit EPIPE. That is normal for a CLI, and without this listener
14
+ // Node reports it as an uncaught exception with a stack trace, bypassing the
15
+ // command's own error handling.
16
+ process.stdout.on('error', (err) => {
17
+ if (err.code === 'EPIPE') {
18
+ process.exit(0);
19
+ }
20
+ throw err;
21
+ });
12
22
  program
13
23
  .name('noxctl')
14
24
  .description('CLI and MCP server for Fortnox accounting')
15
- .version('0.4.1')
25
+ .version('0.5.0')
16
26
  .addOption(new Option('-o, --output <format>', 'Output format (default: table on TTY, json when piped)')
17
27
  .choices(['json', 'table'])
18
28
  .default(undefined))
@@ -1114,6 +1124,111 @@ invoices
1114
1124
  const data = await sendInvoice(documentNumber, opts.method, emailOptions);
1115
1125
  outputConfirmation(`Invoice ${documentNumber} sent via ${opts.method}.`, json(), data, invoiceConfirmColumns, 'Invoice');
1116
1126
  });
1127
+ invoices
1128
+ .command('pdf <documentNumber>')
1129
+ .description('Download an invoice as a PDF')
1130
+ // Note: -o/--output is already taken globally for the output *format*
1131
+ // (json|table), so the destination path is --file.
1132
+ .option('-f, --file <path>', 'Write the PDF here (- for stdout)')
1133
+ .option('--mark-sent', 'Also flag the invoice as sent in Fortnox (uses /print)')
1134
+ .option('-y, --yes', 'Skip confirmation prompt (only needed with --mark-sent)')
1135
+ .option('--dry-run', 'Preview the action without sending it')
1136
+ .addHelpText('after', `
1137
+ The PDF always comes from Fortnox's /preview endpoint, which does not change the
1138
+ invoice. --mark-sent additionally calls /print afterwards to set Sent=true — the
1139
+ file is written first, so a failed write never leaves an invoice flagged as sent
1140
+ with no PDF to show for it.
1141
+
1142
+ Without --file the PDF is written to invoice-<documentNumber>.pdf in the current
1143
+ directory.
1144
+
1145
+ When writing to a file, --mark-sent replaces it with the document /print itself
1146
+ returned, so the saved copy matches the version that was marked. Streaming to
1147
+ stdout cannot do that — bytes already written cannot be recalled — so with
1148
+ --file - the streamed document is the /preview render.
1149
+
1150
+ Examples:
1151
+ noxctl invoices pdf 28
1152
+ noxctl invoices pdf 28 --file ~/Desktop/faktura-28.pdf
1153
+ noxctl invoices pdf 28 --file - > faktura.pdf
1154
+ noxctl invoices pdf 28 --mark-sent --yes`)
1155
+ .action(async (documentNumber, opts) => {
1156
+ const { getInvoicePdf, markInvoicePrinted } = await import('./operations/invoices.js');
1157
+ const toStdout = opts.file === '-';
1158
+ // Only an *explicit* --output json conflicts here. json() alone is not the
1159
+ // test: it defaults to true whenever stdout is piped, which is exactly how
1160
+ // `--file -` is meant to be used.
1161
+ if (toStdout && program.opts().output === 'json') {
1162
+ throw new Error('--file - writes raw PDF bytes to stdout and cannot be combined with --output json.');
1163
+ }
1164
+ // Only --mark-sent mutates the invoice; a plain download needs no confirmation.
1165
+ if (opts.markSent || opts.dryRun) {
1166
+ const action = opts.markSent
1167
+ ? `Download invoice ${documentNumber} as PDF and flag it as sent`
1168
+ : `Download invoice ${documentNumber} as PDF`;
1169
+ if (!(await confirmMutation(action, opts))) {
1170
+ return;
1171
+ }
1172
+ }
1173
+ const pdf = await getInvoicePdf(documentNumber);
1174
+ if (toStdout) {
1175
+ // Wait for the bytes to be flushed before mutating anything: a closed or
1176
+ // broken pipe must not leave the invoice marked as sent.
1177
+ await new Promise((resolve, reject) => {
1178
+ process.stdout.write(pdf, (err) => (err ? reject(err) : resolve()));
1179
+ });
1180
+ if (opts.markSent)
1181
+ await markInvoicePrinted(documentNumber);
1182
+ return;
1183
+ }
1184
+ const path = opts.file ?? `invoice-${documentNumber}.pdf`;
1185
+ writeFileSync(path, pdf);
1186
+ // Only now that the PDF is safely on disk do we change Fortnox. If that
1187
+ // fails, the download still succeeded — say so, or the user is left
1188
+ // thinking the whole command achieved nothing.
1189
+ let printed;
1190
+ if (opts.markSent) {
1191
+ try {
1192
+ printed = await markInvoicePrinted(documentNumber);
1193
+ }
1194
+ catch (err) {
1195
+ throw new Error(`Invoice ${documentNumber} saved to ${path} (${pdf.length} bytes), but marking it as sent failed: ${err instanceof Error ? err.message : String(err)}`);
1196
+ }
1197
+ }
1198
+ // Prefer the document /print actually produced, so the saved copy matches
1199
+ // the version that was marked as sent. Best-effort: the /preview copy is
1200
+ // already written and the invoice is already flagged, so a failure here is
1201
+ // reported as a note rather than raised as a failed operation.
1202
+ let bytes = pdf.length;
1203
+ let note = '';
1204
+ if (printed?.pdf) {
1205
+ try {
1206
+ writeFileSync(path, printed.pdf);
1207
+ bytes = printed.pdf.length;
1208
+ }
1209
+ catch (err) {
1210
+ note += ` Saved file is the /preview copy; rewriting it with the printed version failed: ${err instanceof Error ? err.message : String(err)}`;
1211
+ }
1212
+ }
1213
+ // Only claim the invoice is sent if Fortnox actually said so.
1214
+ if (printed && !printed.confirmed) {
1215
+ note += ` ${String(printed.invoice.Note)}`;
1216
+ }
1217
+ else if (printed && printed.invoice.Sent === true) {
1218
+ note += ' Marked as sent.';
1219
+ }
1220
+ else if (printed) {
1221
+ note += ' Warning: Fortnox still reports this invoice as not sent.';
1222
+ }
1223
+ outputConfirmation(`Invoice ${documentNumber} saved to ${path} (${bytes} bytes).${note}`, json(), {
1224
+ DocumentNumber: documentNumber,
1225
+ Path: path,
1226
+ Bytes: bytes,
1227
+ // Report what Fortnox says the invoice's state is, not what we asked
1228
+ // for; undefined means "not checked" or "could not be confirmed".
1229
+ Sent: printed?.confirmed ? printed.invoice.Sent : undefined,
1230
+ });
1231
+ });
1117
1232
  invoices
1118
1233
  .command('bookkeep <documentNumber>')
1119
1234
  .description('Bookkeep an invoice')