@open-nav/cli 0.1.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/LICENSE +29 -0
- package/README.md +89 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +8 -0
- package/dist/bin.js.map +1 -0
- package/dist/commands.d.ts +37 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +711 -0
- package/dist/commands.js.map +1 -0
- package/dist/config.d.ts +93 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +192 -0
- package/dist/config.js.map +1 -0
- package/dist/errors.d.ts +26 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +25 -0
- package/dist/errors.js.map +1 -0
- package/dist/main.d.ts +23 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +237 -0
- package/dist/main.js.map +1 -0
- package/dist/output.d.ts +37 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +43 -0
- package/dist/output.js.map +1 -0
- package/dist/version.d.ts +7 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +8 -0
- package/dist/version.js.map +1 -0
- package/package.json +57 -0
- package/src/bin.ts +8 -0
- package/src/commands.ts +860 -0
- package/src/config.ts +246 -0
- package/src/errors.ts +27 -0
- package/src/main.ts +264 -0
- package/src/output.ts +85 -0
- package/src/version.ts +10 -0
package/src/commands.ts
ADDED
|
@@ -0,0 +1,860 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
NavApiError,
|
|
5
|
+
faultMessage,
|
|
6
|
+
parseDocument,
|
|
7
|
+
parseTaxNumber,
|
|
8
|
+
serializeDocument,
|
|
9
|
+
validateInvoice,
|
|
10
|
+
type InvoiceData,
|
|
11
|
+
type InvoiceValidationIssue,
|
|
12
|
+
} from '@open-nav/core';
|
|
13
|
+
import {
|
|
14
|
+
MAX_QUERY_DAYS,
|
|
15
|
+
NavClient,
|
|
16
|
+
chunkDateRange,
|
|
17
|
+
iterateInvoices,
|
|
18
|
+
waitForTransaction,
|
|
19
|
+
} from '@open-nav/client';
|
|
20
|
+
import {
|
|
21
|
+
createDataExport,
|
|
22
|
+
embedImage,
|
|
23
|
+
loadTheme,
|
|
24
|
+
renderInvoiceHtml,
|
|
25
|
+
renderInvoicePdf,
|
|
26
|
+
type InvoiceTheme,
|
|
27
|
+
} from '@open-nav/invoicing';
|
|
28
|
+
import { EXIT, UsageError, type ExitCode } from './errors.js';
|
|
29
|
+
import { describeConfig, loadConfig, loadEnvironment, type LoadOptions } from './config.js';
|
|
30
|
+
import { renderFields, renderIssues, writeResult, type Format, type Writer } from './output.js';
|
|
31
|
+
|
|
32
|
+
export interface CommandContext {
|
|
33
|
+
format: Format;
|
|
34
|
+
writer: Writer;
|
|
35
|
+
load: LoadOptions;
|
|
36
|
+
/** Read a file, so tests need no filesystem. */
|
|
37
|
+
readFile?: (path: string) => string;
|
|
38
|
+
/** Write a file, creating parent directories. Injectable for tests. */
|
|
39
|
+
writeFile?: (path: string, contents: string) => void;
|
|
40
|
+
/** Write binary output, for PDFs. */
|
|
41
|
+
writeBinaryFile?: (path: string, contents: Buffer) => void;
|
|
42
|
+
/** Read binary input, for logos. */
|
|
43
|
+
readBinaryFile?: (path: string) => Buffer;
|
|
44
|
+
/** Whether a path already exists, so a download can resume. */
|
|
45
|
+
fileExists?: (path: string) => boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface CommandDefinition {
|
|
49
|
+
name: string;
|
|
50
|
+
summary: string;
|
|
51
|
+
usage: string;
|
|
52
|
+
/** True when the command talks to NAV and therefore needs credentials. */
|
|
53
|
+
needsCredentials: boolean;
|
|
54
|
+
options?: Array<{ flag: string; description: string }>;
|
|
55
|
+
run: (
|
|
56
|
+
positionals: string[],
|
|
57
|
+
flags: Record<string, string | boolean | undefined>,
|
|
58
|
+
context: CommandContext,
|
|
59
|
+
) => Promise<ExitCode>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function client(context: CommandContext): NavClient {
|
|
63
|
+
const config = loadConfig(context.load);
|
|
64
|
+
return new NavClient({
|
|
65
|
+
credentials: config.credentials,
|
|
66
|
+
software: config.software,
|
|
67
|
+
environment: config.environment,
|
|
68
|
+
...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readInput(path: string, context: CommandContext): string {
|
|
73
|
+
const read = context.readFile ?? ((target: string) => readFileSync(target, 'utf8'));
|
|
74
|
+
return path === '-' ? read('/dev/stdin') : read(path);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function writeOutput(path: string, contents: string, context: CommandContext): void {
|
|
78
|
+
if (context.writeFile) {
|
|
79
|
+
context.writeFile(path, contents);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
83
|
+
writeFileSync(path, contents, 'utf8');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function writeBinaryOutput(path: string, contents: Buffer, context: CommandContext): void {
|
|
87
|
+
if (context.writeBinaryFile) {
|
|
88
|
+
context.writeBinaryFile(path, contents);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
92
|
+
writeFileSync(path, contents);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Build the theme from `--theme` and `--logo`.
|
|
97
|
+
*
|
|
98
|
+
* `--logo` wins, so one branded theme can be shared and a single document
|
|
99
|
+
* still overridden without editing it.
|
|
100
|
+
*/
|
|
101
|
+
function resolveThemeFlags(
|
|
102
|
+
flags: Record<string, string | boolean | undefined>,
|
|
103
|
+
context: CommandContext,
|
|
104
|
+
): InvoiceTheme | undefined {
|
|
105
|
+
const themePath = flags['theme'] ? String(flags['theme']) : undefined;
|
|
106
|
+
const logoPath = flags['logo'] ? String(flags['logo']) : undefined;
|
|
107
|
+
if (!themePath && !logoPath) return undefined;
|
|
108
|
+
|
|
109
|
+
const theme: InvoiceTheme = themePath
|
|
110
|
+
? loadTheme(themePath, {
|
|
111
|
+
...(context.readFile ? { readFile: context.readFile } : {}),
|
|
112
|
+
...(context.readBinaryFile ? { readBinary: context.readBinaryFile } : {}),
|
|
113
|
+
})
|
|
114
|
+
: {};
|
|
115
|
+
|
|
116
|
+
if (logoPath) {
|
|
117
|
+
const read = context.readBinaryFile ?? ((target: string) => readFileSync(target));
|
|
118
|
+
theme.logo = { ...theme.logo, src: embedImage(logoPath, read(logoPath)) };
|
|
119
|
+
}
|
|
120
|
+
return theme;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** An invoice number can contain characters a file name cannot. */
|
|
124
|
+
function safeName(invoiceNumber: string): string {
|
|
125
|
+
const cleaned = invoiceNumber.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
126
|
+
return cleaned === '' ? 'invoice' : cleaned;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Read an InvoiceData document, failing clearly if it is something else. */
|
|
130
|
+
function readInvoice(path: string, context: CommandContext): InvoiceData {
|
|
131
|
+
const parsed = parseDocument(readInput(path, context), { unknownElements: 'ignore' });
|
|
132
|
+
if (parsed.root !== 'InvoiceData') {
|
|
133
|
+
throw new UsageError(`${path}: expected an InvoiceData document, found ${parsed.root}`);
|
|
134
|
+
}
|
|
135
|
+
return parsed.value as InvoiceData;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function requirePositional(positionals: string[], index: number, name: string): string {
|
|
139
|
+
const value = positionals[index];
|
|
140
|
+
if (value === undefined || value === '') throw new UsageError(`Missing <${name}>`);
|
|
141
|
+
return value;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Read `--direction`.
|
|
146
|
+
*
|
|
147
|
+
* The default differs by command: a query is usually about your own invoices,
|
|
148
|
+
* while a bulk pull is usually about the ones you received, so each command
|
|
149
|
+
* states its own fallback rather than sharing one.
|
|
150
|
+
*/
|
|
151
|
+
function direction(
|
|
152
|
+
flags: Record<string, string | boolean | undefined>,
|
|
153
|
+
fallback: 'INBOUND' | 'OUTBOUND' = 'OUTBOUND',
|
|
154
|
+
): 'INBOUND' | 'OUTBOUND' {
|
|
155
|
+
const value = flags['direction'] ?? fallback;
|
|
156
|
+
const upper = String(value).toUpperCase();
|
|
157
|
+
if (upper !== 'INBOUND' && upper !== 'OUTBOUND') {
|
|
158
|
+
throw new UsageError('--direction must be inbound or outbound');
|
|
159
|
+
}
|
|
160
|
+
return upper;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function issueSummary(issues: InvoiceValidationIssue[]): string {
|
|
164
|
+
const errors = issues.filter((issue) => issue.severity === 'error').length;
|
|
165
|
+
const warnings = issues.length - errors;
|
|
166
|
+
return `${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export const COMMANDS: CommandDefinition[] = [
|
|
170
|
+
{
|
|
171
|
+
name: 'config',
|
|
172
|
+
summary: 'Show which configuration variables are set, without revealing secrets',
|
|
173
|
+
usage: 'open-nav config',
|
|
174
|
+
needsCredentials: false,
|
|
175
|
+
async run(_positionals, _flags, context) {
|
|
176
|
+
const { env, envFiles } = loadEnvironment(context.load);
|
|
177
|
+
const variables = describeConfig(env);
|
|
178
|
+
const missing = variables
|
|
179
|
+
.filter((variable) => variable.required && !variable.set)
|
|
180
|
+
.map((variable) => variable.variable);
|
|
181
|
+
const data = { envFiles, ready: missing.length === 0, variables, missing };
|
|
182
|
+
|
|
183
|
+
// Required and optional are shown apart: an unset optional variable
|
|
184
|
+
// with a working default is not a problem, and calling it "missing"
|
|
185
|
+
// sends people hunting for something that is not wrong.
|
|
186
|
+
const describe = (variable: (typeof variables)[number]): string => {
|
|
187
|
+
const status = variable.set ? 'set ' : variable.required ? 'MISSING ' : 'default ';
|
|
188
|
+
const shown = variable.set
|
|
189
|
+
? variable.secret
|
|
190
|
+
? ' = ********'
|
|
191
|
+
: ` = ${variable.value}`
|
|
192
|
+
: variable.default !== undefined
|
|
193
|
+
? ` = ${variable.default}`
|
|
194
|
+
: '';
|
|
195
|
+
return ` ${status} ${variable.variable}${shown}`;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
writeResult(context.writer, context.format, 'config', data, () => [
|
|
199
|
+
envFiles.length > 0 ? `Env files: ${envFiles.join(', ')}` : 'Env files: none found',
|
|
200
|
+
'',
|
|
201
|
+
'Required:',
|
|
202
|
+
...variables.filter((variable) => variable.required).map(describe),
|
|
203
|
+
'',
|
|
204
|
+
'Optional:',
|
|
205
|
+
...variables.filter((variable) => !variable.required).map(describe),
|
|
206
|
+
'',
|
|
207
|
+
missing.length === 0
|
|
208
|
+
? 'Ready. Check the credentials for real with: open-nav token'
|
|
209
|
+
: `Not ready: ${missing.join(', ')} still needed.`,
|
|
210
|
+
]);
|
|
211
|
+
return missing.length === 0 ? EXIT.ok : EXIT.usage;
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
{
|
|
216
|
+
name: 'validate',
|
|
217
|
+
summary: 'Validate an invoice XML file locally, without contacting NAV',
|
|
218
|
+
usage:
|
|
219
|
+
'open-nav validate <file.xml|-> [--operation CREATE|MODIFY|STORNO] [--language en|hu|de]',
|
|
220
|
+
needsCredentials: false,
|
|
221
|
+
options: [
|
|
222
|
+
{ flag: '--operation', description: 'Operation the document will be submitted under' },
|
|
223
|
+
{ flag: '--language', description: 'Language for NAV fault descriptions' },
|
|
224
|
+
{ flag: '--warnings-as-errors', description: 'Exit non-zero on warnings too' },
|
|
225
|
+
],
|
|
226
|
+
async run(positionals, flags, context) {
|
|
227
|
+
const path = requirePositional(positionals, 0, 'file');
|
|
228
|
+
const xml = readInput(path, context);
|
|
229
|
+
const parsed = parseDocument(xml, { unknownElements: 'ignore' });
|
|
230
|
+
if (parsed.root !== 'InvoiceData') {
|
|
231
|
+
throw new UsageError(`Expected an InvoiceData document, found ${parsed.root}`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const operation = flags['operation'] ? String(flags['operation']).toUpperCase() : undefined;
|
|
235
|
+
if (operation && !['CREATE', 'MODIFY', 'STORNO'].includes(operation)) {
|
|
236
|
+
throw new UsageError('--operation must be CREATE, MODIFY or STORNO');
|
|
237
|
+
}
|
|
238
|
+
const language = flags['language'] ? String(flags['language']) : 'en';
|
|
239
|
+
if (!['en', 'hu', 'de'].includes(language)) {
|
|
240
|
+
throw new UsageError('--language must be en, hu or de');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const report = validateInvoice(parsed.value as InvoiceData, {
|
|
244
|
+
...(operation ? { operation: operation as 'CREATE' } : {}),
|
|
245
|
+
language: language as 'en',
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const strict = flags['warnings-as-errors'] === true;
|
|
249
|
+
const data = {
|
|
250
|
+
file: path,
|
|
251
|
+
valid: report.valid,
|
|
252
|
+
errorCount: report.errors.length,
|
|
253
|
+
warningCount: report.warnings.length,
|
|
254
|
+
issues: report.issues,
|
|
255
|
+
};
|
|
256
|
+
writeResult(context.writer, context.format, 'validate', data, () => [
|
|
257
|
+
`${path}: ${report.valid ? 'valid' : 'invalid'} (${issueSummary(report.issues)})`,
|
|
258
|
+
...(report.issues.length > 0 ? ['', ...renderIssues(report.issues)] : []),
|
|
259
|
+
]);
|
|
260
|
+
|
|
261
|
+
if (!report.valid) return EXIT.invalid;
|
|
262
|
+
return strict && report.warnings.length > 0 ? EXIT.invalid : EXIT.ok;
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
|
|
266
|
+
{
|
|
267
|
+
name: 'fault',
|
|
268
|
+
summary: "Look up NAV's description of a validation fault code",
|
|
269
|
+
usage: 'open-nav fault <CODE> [--language en|hu|de]',
|
|
270
|
+
needsCredentials: false,
|
|
271
|
+
async run(positionals, flags, context) {
|
|
272
|
+
const code = requirePositional(positionals, 0, 'code').toUpperCase();
|
|
273
|
+
const language = (flags['language'] ? String(flags['language']) : 'en') as 'en';
|
|
274
|
+
const message = faultMessage(code as 'SCHEMA_VIOLATION', language);
|
|
275
|
+
if (!message) throw new UsageError(`No NAV description for fault code ${code}`);
|
|
276
|
+
const data = { code, language, message };
|
|
277
|
+
writeResult(context.writer, context.format, 'fault', data, () => [`${code}: ${message}`]);
|
|
278
|
+
return EXIT.ok;
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
|
|
282
|
+
{
|
|
283
|
+
name: 'token',
|
|
284
|
+
summary: 'Exchange credentials for a token — the quickest end-to-end check',
|
|
285
|
+
usage: 'open-nav token',
|
|
286
|
+
needsCredentials: true,
|
|
287
|
+
async run(_positionals, _flags, context) {
|
|
288
|
+
const result = await client(context).tokenExchange();
|
|
289
|
+
const data = {
|
|
290
|
+
tokenLength: result.token.length,
|
|
291
|
+
validityFrom: result.validityFrom,
|
|
292
|
+
validityTo: result.validityTo,
|
|
293
|
+
};
|
|
294
|
+
writeResult(context.writer, context.format, 'token', data, () => [
|
|
295
|
+
'Credentials accepted, token decrypted.',
|
|
296
|
+
...renderFields([
|
|
297
|
+
['valid from', result.validityFrom],
|
|
298
|
+
['valid to', result.validityTo],
|
|
299
|
+
]),
|
|
300
|
+
]);
|
|
301
|
+
return EXIT.ok;
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
|
|
305
|
+
{
|
|
306
|
+
name: 'taxpayer',
|
|
307
|
+
summary: 'Look up a Hungarian taxpayer by tax number',
|
|
308
|
+
usage: 'open-nav taxpayer <taxNumber>',
|
|
309
|
+
needsCredentials: true,
|
|
310
|
+
async run(positionals, _flags, context) {
|
|
311
|
+
const input = requirePositional(positionals, 0, 'taxNumber');
|
|
312
|
+
// Accept the written 11 digit form and use the core, which is what NAV wants.
|
|
313
|
+
const { taxpayerId } = parseTaxNumber(input);
|
|
314
|
+
const response = await client(context).queryTaxpayer({ taxNumber: taxpayerId });
|
|
315
|
+
const data = {
|
|
316
|
+
taxNumber: taxpayerId,
|
|
317
|
+
valid: response.taxpayerValidity ?? false,
|
|
318
|
+
taxpayer: response.taxpayerData,
|
|
319
|
+
};
|
|
320
|
+
writeResult(context.writer, context.format, 'taxpayer', data, () => [
|
|
321
|
+
...renderFields([
|
|
322
|
+
['tax number', taxpayerId],
|
|
323
|
+
['valid', String(data.valid)],
|
|
324
|
+
['name', response.taxpayerData?.taxpayerName],
|
|
325
|
+
['short name', response.taxpayerData?.taxpayerShortName],
|
|
326
|
+
]),
|
|
327
|
+
]);
|
|
328
|
+
return data.valid ? EXIT.ok : EXIT.rejected;
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
{
|
|
333
|
+
name: 'submit',
|
|
334
|
+
summary: 'Validate and submit invoice XML files',
|
|
335
|
+
usage:
|
|
336
|
+
'open-nav submit <file.xml...> [--operation CREATE] [--wait] [--compress] [--skip-validation]',
|
|
337
|
+
needsCredentials: true,
|
|
338
|
+
options: [
|
|
339
|
+
{ flag: '--operation', description: 'CREATE (default), MODIFY or STORNO' },
|
|
340
|
+
{ flag: '--wait', description: 'Poll until NAV reaches a verdict' },
|
|
341
|
+
{ flag: '--compress', description: 'Gzip the payloads' },
|
|
342
|
+
{ flag: '--skip-validation', description: 'Do not validate before sending' },
|
|
343
|
+
],
|
|
344
|
+
async run(positionals, flags, context) {
|
|
345
|
+
if (positionals.length === 0) throw new UsageError('Missing <file.xml>');
|
|
346
|
+
const operationRaw = flags['operation'] ? String(flags['operation']).toUpperCase() : 'CREATE';
|
|
347
|
+
if (!['CREATE', 'MODIFY', 'STORNO'].includes(operationRaw)) {
|
|
348
|
+
throw new UsageError('--operation must be CREATE, MODIFY or STORNO');
|
|
349
|
+
}
|
|
350
|
+
const operation = operationRaw as 'CREATE';
|
|
351
|
+
|
|
352
|
+
const invoices: InvoiceData[] = [];
|
|
353
|
+
const validationIssues: Array<{ file: string; issues: InvoiceValidationIssue[] }> = [];
|
|
354
|
+
for (const path of positionals) {
|
|
355
|
+
const parsed = parseDocument(readInput(path, context), { unknownElements: 'ignore' });
|
|
356
|
+
if (parsed.root !== 'InvoiceData') {
|
|
357
|
+
throw new UsageError(`${path}: expected an InvoiceData document, found ${parsed.root}`);
|
|
358
|
+
}
|
|
359
|
+
const invoice = parsed.value as InvoiceData;
|
|
360
|
+
invoices.push(invoice);
|
|
361
|
+
|
|
362
|
+
// Validate before sending by default: a rejection costs a round trip
|
|
363
|
+
// and burns the requestId, and NAV's messages are terser than ours.
|
|
364
|
+
if (flags['skip-validation'] !== true) {
|
|
365
|
+
const report = validateInvoice(invoice, { operation });
|
|
366
|
+
if (report.issues.length > 0)
|
|
367
|
+
validationIssues.push({ file: path, issues: report.issues });
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const blocking = validationIssues.filter((entry) =>
|
|
372
|
+
entry.issues.some((issue) => issue.severity === 'error'),
|
|
373
|
+
);
|
|
374
|
+
if (blocking.length > 0) {
|
|
375
|
+
writeResult(
|
|
376
|
+
context.writer,
|
|
377
|
+
context.format,
|
|
378
|
+
'submit',
|
|
379
|
+
{ submitted: false, validation: validationIssues },
|
|
380
|
+
() => [
|
|
381
|
+
'Not submitted: local validation failed.',
|
|
382
|
+
...blocking.flatMap((entry) => [`${entry.file}:`, ...renderIssues(entry.issues)]),
|
|
383
|
+
],
|
|
384
|
+
);
|
|
385
|
+
return EXIT.invalid;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const response = await client(context).submitInvoices(
|
|
389
|
+
invoices.map((invoice) => ({ operation, invoice })),
|
|
390
|
+
{ compress: flags['compress'] === true },
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
if (flags['wait'] !== true) {
|
|
394
|
+
const data = {
|
|
395
|
+
submitted: true,
|
|
396
|
+
transactionId: response.transactionId,
|
|
397
|
+
count: invoices.length,
|
|
398
|
+
validation: validationIssues,
|
|
399
|
+
};
|
|
400
|
+
writeResult(context.writer, context.format, 'submit', data, () => [
|
|
401
|
+
`Submitted ${invoices.length} invoice(s).`,
|
|
402
|
+
...renderFields([['transaction', response.transactionId]]),
|
|
403
|
+
'Check the outcome with: open-nav status ' + response.transactionId,
|
|
404
|
+
]);
|
|
405
|
+
return EXIT.ok;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const outcome = await waitForTransaction(client(context), response.transactionId);
|
|
409
|
+
const data = {
|
|
410
|
+
submitted: true,
|
|
411
|
+
transactionId: response.transactionId,
|
|
412
|
+
accepted: outcome.accepted.length,
|
|
413
|
+
rejected: outcome.rejected.length,
|
|
414
|
+
warnings: outcome.warnings.length,
|
|
415
|
+
results: outcome.results,
|
|
416
|
+
validation: validationIssues,
|
|
417
|
+
};
|
|
418
|
+
writeResult(context.writer, context.format, 'submit', data, () => [
|
|
419
|
+
...renderFields([
|
|
420
|
+
['transaction', response.transactionId],
|
|
421
|
+
['accepted', outcome.accepted.length],
|
|
422
|
+
['rejected', outcome.rejected.length],
|
|
423
|
+
['warnings', outcome.warnings.length],
|
|
424
|
+
]),
|
|
425
|
+
]);
|
|
426
|
+
return outcome.rejected.length > 0 ? EXIT.rejected : EXIT.ok;
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
|
|
430
|
+
{
|
|
431
|
+
name: 'status',
|
|
432
|
+
summary: 'Show the processing status of a submitted transaction',
|
|
433
|
+
usage: 'open-nav status <transactionId> [--wait]',
|
|
434
|
+
needsCredentials: true,
|
|
435
|
+
options: [{ flag: '--wait', description: 'Poll until NAV reaches a verdict' }],
|
|
436
|
+
async run(positionals, flags, context) {
|
|
437
|
+
const transactionId = requirePositional(positionals, 0, 'transactionId');
|
|
438
|
+
const navClient = client(context);
|
|
439
|
+
|
|
440
|
+
if (flags['wait'] === true) {
|
|
441
|
+
const outcome = await waitForTransaction(navClient, transactionId);
|
|
442
|
+
const data = {
|
|
443
|
+
transactionId,
|
|
444
|
+
accepted: outcome.accepted.length,
|
|
445
|
+
rejected: outcome.rejected.length,
|
|
446
|
+
results: outcome.results,
|
|
447
|
+
};
|
|
448
|
+
writeResult(context.writer, context.format, 'status', data, () =>
|
|
449
|
+
renderFields([
|
|
450
|
+
['transaction', transactionId],
|
|
451
|
+
['accepted', outcome.accepted.length],
|
|
452
|
+
['rejected', outcome.rejected.length],
|
|
453
|
+
]),
|
|
454
|
+
);
|
|
455
|
+
return outcome.rejected.length > 0 ? EXIT.rejected : EXIT.ok;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const response = await navClient.queryTransactionStatus({ transactionId });
|
|
459
|
+
const results = response.processingResults?.processingResult ?? [];
|
|
460
|
+
const data = { transactionId, results };
|
|
461
|
+
writeResult(context.writer, context.format, 'status', data, () => [
|
|
462
|
+
...renderFields([
|
|
463
|
+
['transaction', transactionId],
|
|
464
|
+
['invoices', results.length],
|
|
465
|
+
]),
|
|
466
|
+
...results.map((result) => ` #${result.index} ${result.invoiceStatus}`),
|
|
467
|
+
]);
|
|
468
|
+
const pending = results.some(
|
|
469
|
+
(result) => result.invoiceStatus !== 'DONE' && result.invoiceStatus !== 'ABORTED',
|
|
470
|
+
);
|
|
471
|
+
if (results.some((result) => result.invoiceStatus === 'ABORTED')) return EXIT.rejected;
|
|
472
|
+
return pending || results.length === 0 ? EXIT.unavailable : EXIT.ok;
|
|
473
|
+
},
|
|
474
|
+
},
|
|
475
|
+
|
|
476
|
+
{
|
|
477
|
+
name: 'digest',
|
|
478
|
+
summary: 'List invoices issued or received in a date range',
|
|
479
|
+
usage:
|
|
480
|
+
'open-nav digest --from YYYY-MM-DD --to YYYY-MM-DD [--direction outbound|inbound] [--page N]',
|
|
481
|
+
needsCredentials: true,
|
|
482
|
+
options: [
|
|
483
|
+
{ flag: '--from', description: 'First issue date, inclusive' },
|
|
484
|
+
{ flag: '--to', description: 'Last issue date, inclusive' },
|
|
485
|
+
{ flag: '--direction', description: 'outbound (default) or inbound' },
|
|
486
|
+
{ flag: '--page', description: 'Page number, from 1' },
|
|
487
|
+
],
|
|
488
|
+
async run(_positionals, flags, context) {
|
|
489
|
+
const from = flags['from'] ? String(flags['from']) : undefined;
|
|
490
|
+
const to = flags['to'] ? String(flags['to']) : undefined;
|
|
491
|
+
if (!from || !to) throw new UsageError('--from and --to are required (YYYY-MM-DD)');
|
|
492
|
+
const page = flags['page'] ? Number(flags['page']) : 1;
|
|
493
|
+
if (!Number.isInteger(page) || page < 1) throw new UsageError('--page must be 1 or more');
|
|
494
|
+
|
|
495
|
+
const response = await client(context).queryInvoiceDigest({
|
|
496
|
+
page,
|
|
497
|
+
invoiceDirection: direction(flags),
|
|
498
|
+
invoiceQueryParams: {
|
|
499
|
+
mandatoryQueryParams: { invoiceIssueDate: { dateFrom: from, dateTo: to } },
|
|
500
|
+
},
|
|
501
|
+
});
|
|
502
|
+
const digests = response.invoiceDigestResult.invoiceDigest ?? [];
|
|
503
|
+
const data = {
|
|
504
|
+
page: response.invoiceDigestResult.currentPage,
|
|
505
|
+
availablePages: response.invoiceDigestResult.availablePage,
|
|
506
|
+
count: digests.length,
|
|
507
|
+
invoices: digests,
|
|
508
|
+
};
|
|
509
|
+
writeResult(context.writer, context.format, 'digest', data, () => [
|
|
510
|
+
`Page ${data.page} of ${data.availablePages}, ${digests.length} invoice(s).`,
|
|
511
|
+
...digests.map(
|
|
512
|
+
(digest) =>
|
|
513
|
+
` ${digest.invoiceNumber} ${digest.invoiceIssueDate} ` +
|
|
514
|
+
`${digest.supplierTaxNumber} ${digest.invoiceNetAmount ?? ''}`,
|
|
515
|
+
),
|
|
516
|
+
]);
|
|
517
|
+
return EXIT.ok;
|
|
518
|
+
},
|
|
519
|
+
},
|
|
520
|
+
|
|
521
|
+
{
|
|
522
|
+
name: 'invoice',
|
|
523
|
+
summary: 'Fetch one invoice in full and print its XML',
|
|
524
|
+
usage: 'open-nav invoice <invoiceNumber> [--direction outbound|inbound] [--supplier TAXNUMBER]',
|
|
525
|
+
needsCredentials: true,
|
|
526
|
+
options: [
|
|
527
|
+
{ flag: '--direction', description: 'outbound (default) or inbound' },
|
|
528
|
+
{ flag: '--supplier', description: 'Supplier tax number, required for inbound invoices' },
|
|
529
|
+
{ flag: '--xml', description: 'Print the invoice XML rather than JSON' },
|
|
530
|
+
],
|
|
531
|
+
async run(positionals, flags, context) {
|
|
532
|
+
const invoiceNumber = requirePositional(positionals, 0, 'invoiceNumber');
|
|
533
|
+
const requested = direction(flags);
|
|
534
|
+
const supplier = flags['supplier'] ? String(flags['supplier']) : undefined;
|
|
535
|
+
if (requested === 'INBOUND' && !supplier) {
|
|
536
|
+
throw new UsageError('--supplier is required for an inbound invoice');
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const response = await client(context).queryInvoiceData({
|
|
540
|
+
invoiceNumberQuery: {
|
|
541
|
+
invoiceNumber,
|
|
542
|
+
invoiceDirection: requested,
|
|
543
|
+
...(supplier ? { supplierTaxNumber: parseTaxNumber(supplier).taxpayerId } : {}),
|
|
544
|
+
},
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
const result = response.invoiceDataResult;
|
|
548
|
+
if (!result) {
|
|
549
|
+
writeResult(
|
|
550
|
+
context.writer,
|
|
551
|
+
context.format,
|
|
552
|
+
'invoice',
|
|
553
|
+
{ found: false, invoiceNumber },
|
|
554
|
+
() => [`No invoice found for ${invoiceNumber}.`],
|
|
555
|
+
);
|
|
556
|
+
return EXIT.rejected;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const { decodeInvoiceData } = await import('@open-nav/core');
|
|
560
|
+
const invoice = decodeInvoiceData(result.invoiceData, {
|
|
561
|
+
compressed: result.compressedContentIndicator,
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
if (flags['xml'] === true) {
|
|
565
|
+
context.writer.out(serializeDocument('InvoiceData', invoice, { indent: ' ' }));
|
|
566
|
+
return EXIT.ok;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const data = { found: true, invoiceNumber, auditData: result.auditData, invoice };
|
|
570
|
+
writeResult(context.writer, context.format, 'invoice', data, () => [
|
|
571
|
+
serializeDocument('InvoiceData', invoice, { indent: ' ' }),
|
|
572
|
+
]);
|
|
573
|
+
return EXIT.ok;
|
|
574
|
+
},
|
|
575
|
+
},
|
|
576
|
+
|
|
577
|
+
{
|
|
578
|
+
name: 'render',
|
|
579
|
+
summary: 'Render an invoice as a printable HTML or PDF document',
|
|
580
|
+
usage:
|
|
581
|
+
'open-nav render <file.xml> [--pdf file.pdf] [--out file.html] [--theme theme.json] [--logo logo.png] [--engine native|browser] [--language hu|en] [--note text]',
|
|
582
|
+
needsCredentials: false,
|
|
583
|
+
options: [
|
|
584
|
+
{ flag: '--pdf', description: 'Write a PDF, converting with a local browser' },
|
|
585
|
+
{ flag: '--out', description: 'Write HTML here instead of standard output' },
|
|
586
|
+
{ flag: '--theme', description: 'JSON theme: logo, colours, fonts, page setup, footer' },
|
|
587
|
+
{ flag: '--logo', description: 'Image to inline as the logo, overriding the theme' },
|
|
588
|
+
{ flag: '--language', description: 'hu (default) or en' },
|
|
589
|
+
{ flag: '--note', description: 'Extra note printed under the totals' },
|
|
590
|
+
{ flag: '--engine', description: 'native (default, no browser) or browser' },
|
|
591
|
+
{ flag: '--browser', description: 'Browser executable — browser engine only' },
|
|
592
|
+
{ flag: '--no-sandbox', description: 'Disable the browser sandbox — browser engine only' },
|
|
593
|
+
],
|
|
594
|
+
async run(positionals, flags, context) {
|
|
595
|
+
const path = requirePositional(positionals, 0, 'file');
|
|
596
|
+
const invoice = readInvoice(path, context);
|
|
597
|
+
|
|
598
|
+
const requested = flags['language'] ? String(flags['language']) : 'hu';
|
|
599
|
+
if (requested !== 'hu' && requested !== 'en') {
|
|
600
|
+
throw new UsageError('--language must be hu or en');
|
|
601
|
+
}
|
|
602
|
+
const language: 'hu' | 'en' = requested;
|
|
603
|
+
|
|
604
|
+
const theme = resolveThemeFlags(flags, context);
|
|
605
|
+
const renderOptions = {
|
|
606
|
+
language,
|
|
607
|
+
...(theme ? { theme } : {}),
|
|
608
|
+
...(flags['note'] ? { note: String(flags['note']) } : {}),
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
const pdfPath = flags['pdf'] ? String(flags['pdf']) : undefined;
|
|
612
|
+
const out = flags['out'] ? String(flags['out']) : undefined;
|
|
613
|
+
|
|
614
|
+
if (pdfPath) {
|
|
615
|
+
const engineFlag = flags['engine'] ? String(flags['engine']).toLowerCase() : undefined;
|
|
616
|
+
if (engineFlag !== undefined && engineFlag !== 'native' && engineFlag !== 'browser') {
|
|
617
|
+
throw new UsageError('--engine must be native or browser');
|
|
618
|
+
}
|
|
619
|
+
// --browser and --no-sandbox mean nothing to the native engine, so
|
|
620
|
+
// naming either selects the browser rather than being ignored.
|
|
621
|
+
const engine: 'native' | 'browser' =
|
|
622
|
+
engineFlag ?? (flags['browser'] || flags['no-sandbox'] === true ? 'browser' : 'native');
|
|
623
|
+
|
|
624
|
+
const pdf = await renderInvoicePdf(invoice, {
|
|
625
|
+
...renderOptions,
|
|
626
|
+
engine,
|
|
627
|
+
...(flags['browser'] ? { browserPath: String(flags['browser']) } : {}),
|
|
628
|
+
...(flags['no-sandbox'] === true ? { sandbox: false } : {}),
|
|
629
|
+
});
|
|
630
|
+
writeBinaryOutput(pdfPath, pdf, context);
|
|
631
|
+
if (out) writeOutput(out, renderInvoiceHtml(invoice, renderOptions), context);
|
|
632
|
+
|
|
633
|
+
const data = {
|
|
634
|
+
file: path,
|
|
635
|
+
pdf: pdfPath,
|
|
636
|
+
bytes: pdf.length,
|
|
637
|
+
language,
|
|
638
|
+
engine,
|
|
639
|
+
...(out ? { out } : {}),
|
|
640
|
+
};
|
|
641
|
+
writeResult(context.writer, context.format, 'render', data, () => [
|
|
642
|
+
`Wrote ${pdfPath} (${pdf.length} bytes).`,
|
|
643
|
+
...(out ? [`Wrote ${out}.`] : []),
|
|
644
|
+
]);
|
|
645
|
+
return EXIT.ok;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const html = renderInvoiceHtml(invoice, renderOptions);
|
|
649
|
+
if (!out) {
|
|
650
|
+
// Straight to stdout, so it can be piped.
|
|
651
|
+
context.writer.out(html);
|
|
652
|
+
return EXIT.ok;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
writeOutput(out, html, context);
|
|
656
|
+
const data = { file: path, out, bytes: html.length, language };
|
|
657
|
+
writeResult(context.writer, context.format, 'render', data, () => [
|
|
658
|
+
`Wrote ${out} (${html.length} bytes).`,
|
|
659
|
+
`For a PDF instead: open-nav render ${path} --pdf invoice.pdf`,
|
|
660
|
+
]);
|
|
661
|
+
return EXIT.ok;
|
|
662
|
+
},
|
|
663
|
+
},
|
|
664
|
+
|
|
665
|
+
{
|
|
666
|
+
name: 'export',
|
|
667
|
+
summary: 'Produce the tax authority data export for a set of invoices',
|
|
668
|
+
usage:
|
|
669
|
+
'open-nav export <file.xml...> --out <dir> [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--number-from N] [--number-to N]',
|
|
670
|
+
needsCredentials: false,
|
|
671
|
+
options: [
|
|
672
|
+
{ flag: '--out', description: 'Directory to write the export into (required)' },
|
|
673
|
+
{ flag: '--from', description: 'First issue date to include' },
|
|
674
|
+
{ flag: '--to', description: 'Last issue date to include' },
|
|
675
|
+
{ flag: '--number-from', description: 'First invoice number to include' },
|
|
676
|
+
{ flag: '--number-to', description: 'Last invoice number to include' },
|
|
677
|
+
],
|
|
678
|
+
async run(positionals, flags, context) {
|
|
679
|
+
if (positionals.length === 0) throw new UsageError('Missing <file.xml>');
|
|
680
|
+
const out = flags['out'] ? String(flags['out']) : undefined;
|
|
681
|
+
if (!out) throw new UsageError('--out <dir> is required');
|
|
682
|
+
|
|
683
|
+
const invoices = positionals.map((path) => readInvoice(path, context));
|
|
684
|
+
const result = createDataExport(invoices, {
|
|
685
|
+
...(flags['from'] ? { issueDateFrom: String(flags['from']) } : {}),
|
|
686
|
+
...(flags['to'] ? { issueDateTo: String(flags['to']) } : {}),
|
|
687
|
+
...(flags['number-from'] ? { invoiceNumberFrom: String(flags['number-from']) } : {}),
|
|
688
|
+
...(flags['number-to'] ? { invoiceNumberTo: String(flags['number-to']) } : {}),
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
for (const file of result.files) {
|
|
692
|
+
writeOutput(join(out, file.name), file.contents, context);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const data = {
|
|
696
|
+
out,
|
|
697
|
+
considered: invoices.length,
|
|
698
|
+
exported: result.manifest.invoiceCount,
|
|
699
|
+
files: result.files.map((file) => file.name),
|
|
700
|
+
structure: result.manifest.structure,
|
|
701
|
+
};
|
|
702
|
+
writeResult(context.writer, context.format, 'export', data, () => [
|
|
703
|
+
`Exported ${result.manifest.invoiceCount} of ${invoices.length} invoice(s) to ${out}.`,
|
|
704
|
+
`Structure: ${result.manifest.structure.schema}`,
|
|
705
|
+
`Basis: ${result.manifest.structure.basis}`,
|
|
706
|
+
]);
|
|
707
|
+
return EXIT.ok;
|
|
708
|
+
},
|
|
709
|
+
},
|
|
710
|
+
|
|
711
|
+
{
|
|
712
|
+
name: 'pull',
|
|
713
|
+
summary: 'Download invoices for a date range into a directory',
|
|
714
|
+
usage:
|
|
715
|
+
'open-nav pull --out <dir> --from YYYY-MM-DD --to YYYY-MM-DD [--direction inbound|outbound] [--delay ms]',
|
|
716
|
+
needsCredentials: true,
|
|
717
|
+
options: [
|
|
718
|
+
{ flag: '--out', description: 'Directory to write invoices into (required)' },
|
|
719
|
+
{ flag: '--from', description: 'First issue date, inclusive' },
|
|
720
|
+
{ flag: '--to', description: 'Last issue date, inclusive' },
|
|
721
|
+
{ flag: '--direction', description: 'inbound (default) or outbound' },
|
|
722
|
+
{ flag: '--delay', description: 'Pause between requests in ms (default 250)' },
|
|
723
|
+
{ flag: '--refresh', description: 'Re-download invoices already on disk' },
|
|
724
|
+
],
|
|
725
|
+
async run(_positionals, flags, context) {
|
|
726
|
+
const out = flags['out'] ? String(flags['out']) : undefined;
|
|
727
|
+
const from = flags['from'] ? String(flags['from']) : undefined;
|
|
728
|
+
const to = flags['to'] ? String(flags['to']) : undefined;
|
|
729
|
+
if (!out) throw new UsageError('--out <dir> is required');
|
|
730
|
+
if (!from || !to) throw new UsageError('--from and --to are required (YYYY-MM-DD)');
|
|
731
|
+
|
|
732
|
+
const requested = direction(flags, 'INBOUND');
|
|
733
|
+
const delayMs = flags['delay'] ? Number(flags['delay']) : undefined;
|
|
734
|
+
if (delayMs !== undefined && (!Number.isFinite(delayMs) || delayMs < 0)) {
|
|
735
|
+
throw new UsageError('--delay must be a non-negative number of milliseconds');
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// NAV caps a digest query at 35 days, so a longer range is split.
|
|
739
|
+
// Reporting that up front explains why one command makes many requests.
|
|
740
|
+
const windows = chunkDateRange(from, to);
|
|
741
|
+
const navClient = client(context);
|
|
742
|
+
const exists = context.fileExists ?? existsSync;
|
|
743
|
+
|
|
744
|
+
const downloaded: Array<{ invoiceNumber: string; issueDate: string; file: string }> = [];
|
|
745
|
+
const skipped: string[] = [];
|
|
746
|
+
|
|
747
|
+
for await (const entry of iterateInvoices(navClient, {
|
|
748
|
+
direction: requested,
|
|
749
|
+
dateFrom: from,
|
|
750
|
+
dateTo: to,
|
|
751
|
+
...(delayMs !== undefined ? { delayMs } : {}),
|
|
752
|
+
})) {
|
|
753
|
+
// Grouped by month, because a year of invoices in one directory is
|
|
754
|
+
// unusable, and named so a re-run can skip what it already has.
|
|
755
|
+
const relative = join(
|
|
756
|
+
requested.toLowerCase(),
|
|
757
|
+
entry.digest.invoiceIssueDate.slice(0, 7),
|
|
758
|
+
`${safeName(entry.digest.invoiceNumber)}.xml`,
|
|
759
|
+
);
|
|
760
|
+
const target = join(out, relative);
|
|
761
|
+
|
|
762
|
+
if (flags['refresh'] !== true && exists(target)) {
|
|
763
|
+
skipped.push(entry.digest.invoiceNumber);
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
writeOutput(target, entry.xml, context);
|
|
768
|
+
downloaded.push({
|
|
769
|
+
invoiceNumber: entry.digest.invoiceNumber,
|
|
770
|
+
issueDate: entry.digest.invoiceIssueDate,
|
|
771
|
+
file: relative,
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const index = {
|
|
776
|
+
direction: requested,
|
|
777
|
+
dateFrom: from,
|
|
778
|
+
dateTo: to,
|
|
779
|
+
windows,
|
|
780
|
+
downloaded,
|
|
781
|
+
skipped,
|
|
782
|
+
};
|
|
783
|
+
writeOutput(join(out, 'index.json'), `${JSON.stringify(index, null, 2)}\n`, context);
|
|
784
|
+
|
|
785
|
+
const data = {
|
|
786
|
+
out,
|
|
787
|
+
direction: requested,
|
|
788
|
+
windows: windows.length,
|
|
789
|
+
downloaded: downloaded.length,
|
|
790
|
+
skipped: skipped.length,
|
|
791
|
+
};
|
|
792
|
+
writeResult(context.writer, context.format, 'pull', data, () => [
|
|
793
|
+
`Downloaded ${downloaded.length} invoice(s) to ${out}.`,
|
|
794
|
+
...(skipped.length > 0 ? [`Skipped ${skipped.length} already present.`] : []),
|
|
795
|
+
`Queried ${windows.length} window(s) of at most ${MAX_QUERY_DAYS} days.`,
|
|
796
|
+
]);
|
|
797
|
+
return EXIT.ok;
|
|
798
|
+
},
|
|
799
|
+
},
|
|
800
|
+
|
|
801
|
+
{
|
|
802
|
+
name: 'transactions',
|
|
803
|
+
summary: 'List data submissions made in a time range',
|
|
804
|
+
usage: 'open-nav transactions --from <ISO datetime> --to <ISO datetime> [--page N]',
|
|
805
|
+
needsCredentials: true,
|
|
806
|
+
options: [
|
|
807
|
+
{ flag: '--from', description: 'Start of the window, as an ISO timestamp' },
|
|
808
|
+
{ flag: '--to', description: 'End of the window, as an ISO timestamp' },
|
|
809
|
+
{ flag: '--page', description: 'Page number, from 1' },
|
|
810
|
+
],
|
|
811
|
+
async run(_positionals, flags, context) {
|
|
812
|
+
const from = flags['from'] ? String(flags['from']) : undefined;
|
|
813
|
+
const to = flags['to'] ? String(flags['to']) : undefined;
|
|
814
|
+
if (!from || !to) throw new UsageError('--from and --to are required (ISO timestamps)');
|
|
815
|
+
const page = flags['page'] ? Number(flags['page']) : 1;
|
|
816
|
+
|
|
817
|
+
const response = await client(context).queryTransactionList({
|
|
818
|
+
page,
|
|
819
|
+
insDate: { dateTimeFrom: from, dateTimeTo: to },
|
|
820
|
+
});
|
|
821
|
+
const transactions = response.transactionListResult.transaction ?? [];
|
|
822
|
+
const data = {
|
|
823
|
+
page: response.transactionListResult.currentPage,
|
|
824
|
+
availablePages: response.transactionListResult.availablePage,
|
|
825
|
+
transactions,
|
|
826
|
+
};
|
|
827
|
+
writeResult(context.writer, context.format, 'transactions', data, () => [
|
|
828
|
+
`Page ${data.page} of ${data.availablePages}, ${transactions.length} transaction(s).`,
|
|
829
|
+
...transactions.map(
|
|
830
|
+
(transaction) =>
|
|
831
|
+
` ${transaction.transactionId} ${transaction.insDate} ${transaction.requestStatus ?? ''}`,
|
|
832
|
+
),
|
|
833
|
+
]);
|
|
834
|
+
return EXIT.ok;
|
|
835
|
+
},
|
|
836
|
+
},
|
|
837
|
+
];
|
|
838
|
+
|
|
839
|
+
export function findCommand(name: string): CommandDefinition | undefined {
|
|
840
|
+
return COMMANDS.find((command) => command.name === name);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** Machine-readable description of the whole surface, for tooling and agents. */
|
|
844
|
+
export function describeCommands(): unknown {
|
|
845
|
+
return {
|
|
846
|
+
name: 'open-nav',
|
|
847
|
+
description: 'Command line access to the NAV Online Számla 3.0 invoice service',
|
|
848
|
+
exitCodes: EXIT,
|
|
849
|
+
configuration: 'Set NAV_* environment variables, or put them in a .env file.',
|
|
850
|
+
commands: COMMANDS.map((command) => ({
|
|
851
|
+
name: command.name,
|
|
852
|
+
summary: command.summary,
|
|
853
|
+
usage: command.usage,
|
|
854
|
+
needsCredentials: command.needsCredentials,
|
|
855
|
+
options: command.options ?? [],
|
|
856
|
+
})),
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
export { NavApiError };
|