beliq-cli 0.3.0 → 0.4.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
@@ -1,8 +1,8 @@
1
1
  # beliq-cli
2
2
 
3
- Validate, generate, parse, and convert EU e-invoices (XRechnung, ZUGFeRD, Factur-X, Peppol BIS) from the terminal or CI, against beliq's authority-pinned, drift-checked rules.
3
+ Validate, generate, parse, and convert EU e-invoices (XRechnung, ZUGFeRD, Factur-X, Peppol BIS) from the terminal or CI, through the beliq API. Every verdict states how deep its check went.
4
4
 
5
- The headline use is **validating e-invoices in CI**: point it at your invoice files, and a non-compliant document fails the build.
5
+ The headline use is **validating e-invoices in CI**: point it at your invoice files, and an invalid document fails the build.
6
6
 
7
7
  ## Install
8
8
 
@@ -18,7 +18,7 @@ Requires Node.js >= 20.15.
18
18
 
19
19
  ```
20
20
  beliq validate <file|dir|-> [<file|dir> ...] [--format auto|cii|ubl] [--fail-on error|warning] [--france-ctc] [--content-type <type>] [--json]
21
- beliq generate <invoice.json|-> --standard xrechnung|zugferd|facturx|peppol-bis [--pdf] [--facturx-profile basicwl|en16931|extended|extended-ctc-fr] [--no-verify] [--seal] [--output <file>] [--json]
21
+ beliq generate <invoice.json|-> --standard xrechnung|zugferd|facturx|peppol-bis|fatturapa|facturae|eslog|ksef [--pdf] [--facturx-profile basicwl|en16931|extended|extended-ctc-fr] [--no-verify] [--seal] [--output <file>] [--json]
22
22
  beliq parse <file|-> [--format auto|cii|ubl] [--content-type <type>] [--json]
23
23
  beliq convert <file|-> --target-format cii|ubl|zugferd|facturx|xrechnung|peppol-bis [--source-format auto|cii|ubl|zugferd|facturx|xrechnung|peppol-bis] [--target-profile basicwl|en16931|extended|extended-ctc-fr] [--content-type <type>] [--output <file>] [--json]
24
24
  beliq me [--json]
@@ -30,6 +30,26 @@ beliq me [--json]
30
30
 
31
31
  `validate` accepts an XML document or a ZUGFeRD/Factur-X PDF. A file argument of `-` reads from stdin. Pass several files, a shell glob, or a directory (its `.xml`/`.pdf` files, recursively) to validate a batch: you get a per-file verdict, a summary, and one exit code for the whole run. A directory is a batch even when it holds one file, so its `--json` shape does not depend on how many invoices it contains. `--france-ctc` also applies the French CTC rules (BR-FR-CTC) to each document. `--json` prints the raw API result (a report object in batch mode) as the only thing on stdout, so it pipes cleanly.
32
32
 
33
+ `convert` returns XML for every target, `facturx` and `zugferd` included: the API does not wrap an XML source into a hybrid PDF. A PDF comes back only when a hybrid PDF source already is the target, and it needs `--output`. A successful convert means the API validated the converted XML before returning it. For a `facturx` or `zugferd` target at profile `en16931` that validation is the one for plain EN 16931 CII: no Factur-X profile schema is checked. It prints no verdict and no check depth. When the engine recorded source elements with no target equivalent, the summary gives their count; a loss the engine does not detect is not counted.
34
+
35
+ `parse` prints a one-line summary and, below it, each warning the API sent about what the document holds and the parsed invoice does not.
36
+
37
+ ## Reading a verdict
38
+
39
+ ```
40
+ $ beliq validate invoice.xml
41
+ VALID fatturapa (profile italy-fatturapa-ordinaria-fpr12) Schema-checked
42
+ 0 errors, 0 warnings
43
+ ```
44
+
45
+ - `VALID` and `INVALID` are the API's `valid`. `valid` means the document passed the rule packs and schemas beliq runs for its format, in the versions the result names. It does not mean every recipient, validation tool or tax authority accepts it.
46
+ - The third field is the check depth, printed as the API sends it in `verificationBadgeLabel`. [How verification works](https://docs.beliq.eu/compliance/how-verification-works/) explains each depth. Where the API sends no label, the CLI prints `check depth not stated` and names no depth.
47
+ - For a PDF, a line says which embedded XML the verdict is about. The PDF itself is not checked.
48
+ - When the result carries `franceCtcBlockingRuleIds`, the CLI prints the API's message for them, with the rule ids, and fails the file with exit code 1 under every `--fail-on`, also where the verdict is `VALID`. The API reports those ids beside `valid` and does not fold them into it.
49
+ - The batch table has the same depth in its `DEPTH` column. `--json` carries every field of the API result.
50
+
51
+ `generate` names the same depth in its summary, and `not validated` with `--no-verify`. The verdict is about the invoice XML, so for `--pdf` the summary says so and names the kind of PDF.
52
+
33
53
  ```bash
34
54
  # Validate a file, human-readable
35
55
  beliq validate invoice.xml
@@ -69,18 +89,18 @@ The exit code is the contract that makes it useful in scripts and CI:
69
89
  | Code | Meaning |
70
90
  |---|---|
71
91
  | 0 | success, or a valid document |
72
- | 1 | document invalid (`validate`, per `--fail-on`) |
92
+ | 1 | document invalid (`validate`, per `--fail-on`), or a result that carries `franceCtcBlockingRuleIds` |
73
93
  | 2 | usage error (bad flag, missing argument, missing API key, PDF without `--output`) |
74
94
  | 3 | beliq API error (bad key, quota, engine, a rejected document), the API could not be reached (DNS, a refused connection, a timeout), or an unexpected error. Never a verdict on the document. |
75
95
  | 4 | I/O error (unreadable input, or an output path that already exists) |
76
96
 
77
- In batch mode (many files or a directory) the code covers the whole run: `0` if every file passes, `1` if a document fails `--fail-on`, `3` if any file could not be checked (unreadable, or the API errored on it).
97
+ In batch mode (many files or a directory) the code covers the whole run: `0` if every file passes, `1` if a document fails `--fail-on` or carries `franceCtcBlockingRuleIds`, `3` if any file could not be checked (unreadable, or the API errored on it).
78
98
 
79
- On an API error the message goes to stderr, followed by the failed rules when the API names them (`generate` refuses a document that fails its own validation with a 422 that lists them). With `--json`, stdout also gets the error as `{ "error": { "status", "code", "message", "details" } }`.
99
+ On an API error the message goes to stderr, followed by the failed rules when the API names them (`generate` and `convert` refuse a document that fails the API's validation with a 422 that lists them). With `--json`, stdout also gets the error as `{ "error": { "status", "code", "message", "details" } }`.
80
100
 
81
101
  ## In CI
82
102
 
83
- Validate every invoice a build produces and fail on a non-compliant one:
103
+ Validate every invoice a build produces and fail on an invalid one:
84
104
 
85
105
  ```yaml
86
106
  - run: npx beliq-cli validate dist/invoices --fail-on error
@@ -88,13 +108,15 @@ Validate every invoice a build produces and fail on a non-compliant one:
88
108
  BELIQ_API_KEY: ${{ secrets.BELIQ_API_KEY }}
89
109
  ```
90
110
 
91
- `beliq validate` takes a directory, so this validates every `.xml`/`.pdf` invoice under `dist/invoices` in one run and fails the step if any is non-compliant. For a per-file step summary and job outputs on GitHub, use the Action `beliq-eu/beliq-validate-action`.
111
+ `beliq validate` takes a directory, so this validates every `.xml`/`.pdf` invoice under `dist/invoices` in one run and fails the step if any is invalid. For a per-file step summary and job outputs on GitHub, use the Action `beliq-eu/beliq-validate-action`.
92
112
 
93
113
  ## Development
94
114
 
95
115
  ```bash
96
116
  npm install
97
117
  npm run build
118
+ npm run typecheck # src and tests
119
+ npm run lint # ESLint with type information, whole repository
98
120
  npm test # unit tests, no network
99
121
  npm run scrub:check # no em-dash
100
122
  BELIQ_API_KEY=... npm run test:integration # live smoke against the real API
package/dist/cli.js CHANGED
@@ -76,9 +76,10 @@ export async function main(argv, io, env = process.env) {
76
76
  }
77
77
  /**
78
78
  * An API error on stderr, followed by the rules a rejected document failed when
79
- * the API names them (a 422 from generate carries its validation result in
80
- * `details`). With --json the error object goes to stdout as well, so a script
81
- * reading stdout gets a payload on failure too.
79
+ * the API names them (a 422 from generate or convert carries the validation
80
+ * result of the document it refused in `details`). With --json the error
81
+ * object goes to stdout as well, so a script reading stdout gets a payload on
82
+ * failure too.
82
83
  */
83
84
  function reportApiError(err, args, io) {
84
85
  const code = err.code ? ` (${err.code})` : '';
@@ -1,14 +1,18 @@
1
1
  import { LIVE_CONVERT_SOURCE_FORMATS, LIVE_CONVERT_TARGET_FORMATS, LIVE_PROFILES, } from '@beliq/sdk';
2
+ import { sniffContentType } from '@beliq/sdk/helpers';
2
3
  import { flagStr, oneOf, requirePositional } from '../args.js';
3
4
  import { UsageError } from '../errors.js';
4
5
  import { emitDocument } from './emit.js';
5
- /** Convert targets that produce a hybrid PDF rather than an XML document. */
6
- const PDF_TARGETS = new Set(['facturx', 'zugferd']);
6
+ /** The targets for which the API can hand a hybrid PDF source back as a PDF. */
7
+ const HYBRID_TARGETS = new Set(['facturx', 'zugferd']);
7
8
  /**
8
- * Convert a document from one EN 16931 format to another. An XML target is
9
- * printed to stdout (or --output); a PDF target (facturx / zugferd) must go to
10
- * --output. Elements the conversion could not carry across are surfaced so a
11
- * lossy conversion is visible.
9
+ * Convert a document from one EN 16931 format to another. The API returns XML
10
+ * for every target, facturx and zugferd included: it does not wrap an XML
11
+ * source into a hybrid PDF. A PDF comes back only when a hybrid PDF source
12
+ * already is the target, and a PDF must go to --output. A 200 means the API
13
+ * validated the converted XML before returning it; it carries no verdict, so
14
+ * no check depth is printed. The count of lost elements is what the engine
15
+ * recorded, and a loss it does not detect is not in it.
12
16
  */
13
17
  export async function runConvert(args, deps, io) {
14
18
  const file = requirePositional(args, 'beliq convert <file|-> --target-format <format>');
@@ -22,18 +26,25 @@ export async function runConvert(args, deps, io) {
22
26
  // the API ignores it for the others.
23
27
  const targetProfile = oneOf(flagStr(args, 'target-profile'), LIVE_PROFILES, 'target-profile');
24
28
  const bytes = await io.readInput(file);
29
+ if (sniffContentType(bytes) === 'application/pdf' && HYBRID_TARGETS.has(targetFormat) && !flagStr(args, 'output')) {
30
+ throw new UsageError(`a PDF source converted to ${targetFormat} can come back as a PDF and needs --output <file>`);
31
+ }
25
32
  const result = await deps.client.convert(bytes, {
26
33
  targetFormat: targetFormat,
27
34
  sourceFormat: sourceFormat,
28
35
  targetProfile: targetProfile,
29
36
  contentType: flagStr(args, 'content-type'),
30
37
  });
31
- const kind = PDF_TARGETS.has(targetFormat) ? 'pdf' : 'xml';
38
+ const kind = result.contentType.includes('pdf') ? 'pdf' : 'xml';
32
39
  const from = result.meta.sourceFormat ? `${result.meta.sourceFormat} ` : '';
33
40
  const to = result.meta.targetFormat ?? targetFormat;
34
41
  const lost = result.meta.lostElementsCount && result.meta.lostElementsCount > 0
35
- ? ` ${result.meta.lostElementsCount} element(s) could not be carried across.`
42
+ ? ` The engine recorded ${result.meta.lostElementsCount} source element(s) with no target equivalent.`
36
43
  : '';
44
+ const sandbox = result.meta.livemode === false ? ' (sandbox)' : '';
45
+ const checked = kind === 'pdf'
46
+ ? ' The API validated the embedded XML before returning it; the PDF around it was not checked.'
47
+ : ' The API validated the converted XML before returning it.';
37
48
  return emitDocument(io, args, {
38
49
  kind,
39
50
  bytes: result.bytes,
@@ -43,8 +54,10 @@ export async function runConvert(args, deps, io) {
43
54
  profileDetected: result.meta.profileDetected,
44
55
  lostElementsCount: result.meta.lostElementsCount,
45
56
  lostElements: result.meta.lostElements,
57
+ conversionTools: result.meta.conversionTools,
58
+ livemode: result.meta.livemode,
46
59
  contentType: result.contentType,
47
60
  },
48
- summary: `Converted ${from}to ${to}.${lost}`,
61
+ summary: `Converted ${from}to ${to}, returned as ${kind === 'pdf' ? 'a PDF' : 'XML'}${sandbox}.${checked}${lost}`,
49
62
  });
50
63
  }
@@ -2,10 +2,13 @@ import { LIVE_GENERATE_STANDARDS, isProfileAllowedForStandard, profilesForStanda
2
2
  import { flagBool, flagStr, oneOf, requirePositional } from '../args.js';
3
3
  import { UsageError } from '../errors.js';
4
4
  import { emitDocument } from './emit.js';
5
+ import { checkDepth, franceBlockLines, verdictWord } from '../verdict.js';
5
6
  /**
6
- * Generate a compliant document from an EN 16931 invoice given as JSON. XML is
7
- * printed to stdout (or --output); a PDF must go to --output. verify defaults on
8
- * so a non-compliant document fails closed rather than being handed back.
7
+ * Generate a document from an EN 16931 invoice given as JSON. XML is printed to
8
+ * stdout (or --output); a PDF must go to --output. verify defaults on, so the
9
+ * API refuses a document that fails its own validation instead of handing it
10
+ * back. The summary names how deep that validation went. The verdict is about
11
+ * the invoice XML, so for a PDF the summary says so and names the kind of PDF.
9
12
  */
10
13
  export async function runGenerate(args, deps, io) {
11
14
  const file = requirePositional(args, 'beliq generate <invoice.json|-> --standard <standard>');
@@ -43,11 +46,20 @@ export async function runGenerate(args, deps, io) {
43
46
  // render theirs either way, so this is inert for them.
44
47
  template: output === 'pdf' ? 'standard' : undefined,
45
48
  verify: !flagBool(args, 'no-verify'),
46
- seal,
49
+ // Always the JSON envelope: it is the only response that carries the
50
+ // verdict's check depth, and the API does the same work for either shape.
51
+ // --seal decides whether the hash and the verdict are printed.
52
+ seal: true,
47
53
  });
48
- const checked = result.meta.schematronVersion
49
- ? `, checked against Schematron ${result.meta.schematronVersion}`
50
- : '';
54
+ const verdict = result.validationResult;
55
+ // `verified: false` is the API's statement that --no-verify skipped validation.
56
+ const ran = verdict?.verified !== false;
57
+ const schematron = ran && result.meta.schematronVersion ? `checked against Schematron ${result.meta.schematronVersion}` : '';
58
+ const depth = verdict === undefined ? '' : ran ? checkDepth(verdict) : 'not validated';
59
+ const check = [schematron, depth].filter((part) => part !== '').join(', ');
60
+ const pdfKind = output === 'pdf' && result.meta.pdfKind ? ` (${result.meta.pdfKind})` : '';
61
+ const checked = check === '' ? '' : output === 'pdf' ? `; invoice XML ${check}` : `, ${check}`;
62
+ const france = verdict ? franceBlockLines(verdict).map((line) => ` ${line}`).join('') : '';
51
63
  const sandbox = result.meta.livemode === false ? ' (sandbox)' : '';
52
64
  const meta = {
53
65
  output,
@@ -56,16 +68,15 @@ export async function runGenerate(args, deps, io) {
56
68
  pdfKind: result.meta.pdfKind,
57
69
  outputEnvelope: result.meta.outputEnvelope,
58
70
  livemode: result.meta.livemode,
71
+ verificationBadge: verdict?.verificationBadge,
72
+ verificationBadgeLabel: verdict?.verificationBadgeLabel,
59
73
  };
60
- let summary = `Generated a ${standard} ${output} document${checked}${sandbox}.`;
74
+ let summary = `Generated a ${standard} ${output} document${pdfKind}${checked}${sandbox}.${france}`;
61
75
  if (seal) {
62
76
  meta.sha256 = result.sha256;
63
77
  meta.rulesetSha256 = result.meta.rulesetSha256;
64
- meta.validationResult = result.validationResult;
65
- const verdict = result.validationResult
66
- ? ` Validation ${result.validationResult.valid ? 'passed' : 'failed'}.`
67
- : '';
68
- summary += ` sha256 ${result.sha256}.${verdict}`;
78
+ meta.validationResult = verdict;
79
+ summary += ` sha256 ${result.sha256}.${verdict ? ` Verdict: ${verdictWord(verdict)}.` : ''}`;
69
80
  }
70
81
  return emitDocument(io, args, { kind: output, bytes: result.bytes, meta, summary });
71
82
  }
@@ -2,7 +2,7 @@ import { LIVE_PARSE_FORMATS } from '@beliq/sdk';
2
2
  import { flagBool, flagStr, oneOf, requirePositional } from '../args.js';
3
3
  import { EXIT } from '../exit.js';
4
4
  import { renderParseHuman } from '../format/parse.js';
5
- /** Parse one document into a structured EN 16931 invoice. */
5
+ /** Parse one document into a structured EN 16931 invoice, with the API's warnings about what it did not return. */
6
6
  export async function runParse(args, deps, io) {
7
7
  const file = requirePositional(args, 'beliq parse <file|->');
8
8
  const format = oneOf(flagStr(args, 'format'), LIVE_PARSE_FORMATS, 'format');
@@ -8,12 +8,13 @@ const FAIL_ON = ['error', 'warning'];
8
8
  // rate-limited), not one bad document. Fail fast rather than retry per file.
9
9
  const FATAL_API_STATUSES = new Set([401, 403, 429]);
10
10
  /**
11
- * Validate one or many documents against beliq's authority-pinned rules.
11
+ * Validate one or many documents. Each verdict names how deep its check went.
12
12
  *
13
13
  * One file (or `-`) named on its own keeps the classic per-document output.
14
14
  * Multiple inputs, or a directory (its `.xml`/`.pdf` files, recursively), switch
15
15
  * to a batch: a per-file verdict, an aggregate summary, and a batch exit code (0
16
- * all pass, 1 some document fails --fail-on, 3 some file could not be checked).
16
+ * all pass, 1 some document fails --fail-on or carries France CTC blocking rule
17
+ * ids, 3 some file could not be checked).
17
18
  * A directory stays a batch even when it holds one invoice, so the --json shape
18
19
  * follows what was asked for rather than how many files were found.
19
20
  */
@@ -39,7 +40,7 @@ export async function runValidate(args, deps, io) {
39
40
  const single = args.positionals.length === 1 && files.length === 1 && files[0] === args.positionals[0];
40
41
  if (single) {
41
42
  const result = await deps.client.validate(await io.readInput(files[0]), { format, contentType, franceCtc });
42
- io.stdout(json ? `${JSON.stringify(result, null, 2)}\n` : `${renderValidationHuman(result)}\n`);
43
+ io.stdout(json ? `${JSON.stringify(result, null, 2)}\n` : `${renderValidationHuman(result, { franceFailsFile: true })}\n`);
43
44
  return computeExitCode(result, failOn);
44
45
  }
45
46
  return runBatch(files, { format, contentType, franceCtc, failOn, json }, deps, io);
package/dist/exit.js CHANGED
@@ -1,7 +1,10 @@
1
1
  export const EXIT = {
2
2
  /** Success, or a document that passed validation. */
3
3
  OK: 0,
4
- /** A document that failed validation per the chosen --fail-on threshold. */
4
+ /**
5
+ * A document that failed validation per the chosen --fail-on threshold, or one
6
+ * whose result carries France CTC blocking rule ids.
7
+ */
5
8
  INVALID: 1,
6
9
  /** A usage problem: bad flag, missing argument, missing API key, PDF without --output. */
7
10
  USAGE: 2,
@@ -17,11 +20,15 @@ export const EXIT = {
17
20
  /**
18
21
  * The CI contract: EXIT.OK when the document passes the chosen threshold,
19
22
  * EXIT.INVALID otherwise. `error` (default) fails on any error; `warning` also
20
- * fails on any warning.
23
+ * fails on any warning. A result that carries France CTC blocking rule ids
24
+ * fails under both: the API reports those ids beside `valid`, also where `valid`
25
+ * is true, so the default threshold alone would let such a result pass.
21
26
  */
22
27
  export function computeExitCode(result, failOn) {
23
28
  const errorCount = result.errors?.length ?? 0;
24
29
  const warningCount = result.warnings?.length ?? 0;
30
+ if ((result.franceCtcBlockingRuleIds?.length ?? 0) > 0)
31
+ return EXIT.INVALID;
25
32
  const failsOnError = !result.valid || errorCount > 0;
26
33
  if (failOn === 'warning')
27
34
  return failsOnError || warningCount > 0 ? EXIT.INVALID : EXIT.OK;
@@ -2,9 +2,10 @@ function plural(n, word) {
2
2
  return `${n} ${word}${n === 1 ? '' : 's'}`;
3
3
  }
4
4
  /**
5
- * A one-line summary of a parse: the detected syntax and profile, then the
6
- * invoice number, line count, and gross total. `--json` emits the full parsed
7
- * invoice instead.
5
+ * A summary of a parse: the detected syntax and profile, then the invoice
6
+ * number, line count, and gross total, then each warning the API sent about
7
+ * what the document holds and the parsed invoice does not. `--json` emits the
8
+ * full result instead.
8
9
  */
9
10
  export function renderParseHuman(result) {
10
11
  const invoice = result.invoice;
@@ -14,5 +15,15 @@ export function renderParseHuman(result) {
14
15
  const gross = invoice?.totalGrossAmount != null
15
16
  ? `, gross ${invoice.totalGrossAmount} ${invoice.currencyCode ?? ''}`.trimEnd()
16
17
  : '';
17
- return `Parsed a ${result.format}${profile} document: ${number}, ${plural(lines, 'line')}${gross}`;
18
+ const head = `Parsed a ${result.format}${profile} document: ${number}, ${plural(lines, 'line')}${gross}`;
19
+ const warnings = result.warnings ?? [];
20
+ if (warnings.length === 0)
21
+ return head;
22
+ const listed = warnings.map((w) => {
23
+ const field = w.field ? ` (field ${w.field})` : '';
24
+ const terms = w.terms?.length ? ` Terms: ${w.terms.join(', ')}.` : '';
25
+ const elements = w.elements?.length ? ` ${plural(w.elements.length, 'element path')}; --json lists them.` : '';
26
+ return `- ${w.code}${field}: ${w.message}${terms}${elements}`;
27
+ });
28
+ return [head, `${plural(warnings.length, 'warning')}:`, ...listed].join('\n');
18
29
  }
@@ -1,3 +1,4 @@
1
+ import { checkDepth, franceBlockLines, franceBlockingRuleIds, pdfInputLine, rulesetChannelLine, verdictWord, } from '../verdict.js';
1
2
  function plural(n, word) {
2
3
  return `${n} ${word}${n === 1 ? '' : 's'}`;
3
4
  }
@@ -13,42 +14,66 @@ function renderTable(headers, rows) {
13
14
  return [line(headers), ...rows.map(line)].join('\n');
14
15
  }
15
16
  /**
16
- * A human-readable verdict: the pass/fail line with format, profile, and
17
- * ruleset version, the issue counts, and an aligned table of the errors then
18
- * warnings. `--json` emits the raw result instead.
17
+ * A human-readable verdict: the API's verdict word with the format, the
18
+ * profile, the check depth and the ruleset version, then the issue counts, what
19
+ * the API says about the ruleset channel, a PDF input and a France CTC block,
20
+ * and an aligned table of the errors then warnings. `--json` emits the raw
21
+ * result instead. `franceFailsFile` adds what a France CTC block does to the
22
+ * exit code of `validate`; a verdict printed for a refused generate or convert
23
+ * leaves it out, because that run exits 3.
19
24
  */
20
- export function renderValidationHuman(result) {
25
+ export function renderValidationHuman(result, opts = {}) {
21
26
  const errors = (result.errors ?? []);
22
27
  const warnings = (result.warnings ?? []);
23
- const verdict = result.valid ? 'VALID' : 'INVALID';
24
28
  const profile = result.profileDetected ? ` (profile ${result.profileDetected})` : '';
25
- const schematron = result.schematronVersion ? ` checked against Schematron ${result.schematronVersion}` : '';
26
- const head = `${verdict} ${result.format}${profile}${schematron}`;
29
+ // Nothing ran when `verified` is false, so there is no depth and no ruleset to name.
30
+ const ran = result.verified !== false;
31
+ const depth = ran ? ` ${checkDepth(result)}` : '';
32
+ const schematron = ran && result.schematronVersion ? ` checked against Schematron ${result.schematronVersion}` : '';
33
+ const head = `${verdictWord(result)} ${result.format}${profile}${depth}${schematron}`;
27
34
  const counts = `${plural(errors.length, 'error')}, ${plural(warnings.length, 'warning')}`;
35
+ const france = franceBlockLines(result);
36
+ const notes = [
37
+ rulesetChannelLine(result),
38
+ pdfInputLine(result),
39
+ ...france,
40
+ ...(france.length > 0 && opts.franceFailsFile
41
+ ? ['This file is reported as failed (exit 1) under every --fail-on.']
42
+ : []),
43
+ ].filter((line) => line !== undefined);
44
+ const top = [head, counts, ...notes].join('\n');
28
45
  const issues = [...errors, ...warnings];
29
46
  if (issues.length === 0)
30
- return `${head}\n${counts}`;
47
+ return top;
31
48
  const rows = issues.map((i) => [i.severity, i.ruleId, i.location || '-', i.message]);
32
- return `${head}\n${counts}\n\n${renderTable(['SEVERITY', 'RULE', 'LOCATION', 'MESSAGE'], rows)}`;
49
+ return `${top}\n\n${renderTable(['SEVERITY', 'RULE', 'LOCATION', 'MESSAGE'], rows)}`;
33
50
  }
34
51
  /**
35
52
  * A batch verdict: a per-file table (PASS / FAIL / ERROR with error and warning
36
- * counts) and a one-line summary. The exit code, not this text, is the CI
37
- * contract; this is the human view.
53
+ * counts and the check depth), a line for each file a France CTC block fails,
54
+ * and a one-line summary. The exit code, not this text, is the CI contract;
55
+ * this is the human view.
38
56
  */
39
57
  export function renderBatchHuman(rows) {
40
58
  const cells = rows.map((r) => 'error' in r
41
- ? ['ERROR', '-', '-', `${r.file} (${r.error})`]
59
+ ? ['ERROR', '-', '-', '-', `${r.file} (${r.error})`]
42
60
  : [
43
61
  r.fails ? 'FAIL' : 'PASS',
44
62
  String((r.result.errors ?? []).length),
45
63
  String((r.result.warnings ?? []).length),
64
+ checkDepth(r.result),
46
65
  r.file,
47
66
  ]);
48
- const table = renderTable(['STATUS', 'ERR', 'WARN', 'FILE'], cells);
67
+ const table = renderTable(['STATUS', 'ERR', 'WARN', 'DEPTH', 'FILE'], cells);
68
+ const blocked = rows.flatMap((r) => {
69
+ if ('error' in r)
70
+ return [];
71
+ const ids = franceBlockingRuleIds(r.result);
72
+ return ids.length > 0 ? [`${r.file}: France CTC blocking rule ids: ${ids.join(', ')}`] : [];
73
+ });
49
74
  const passed = rows.filter((r) => 'result' in r && !r.fails).length;
50
75
  const failed = rows.filter((r) => 'result' in r && r.fails).length;
51
76
  const errored = rows.filter((r) => 'error' in r).length;
52
77
  const summary = `${plural(rows.length, 'file')}: ${passed} passed, ${failed} failed, ${errored} errored`;
53
- return `${table}\n\n${summary}`;
78
+ return [table, ...(blocked.length > 0 ? [blocked.join('\n')] : []), summary].join('\n\n');
54
79
  }
package/dist/help.js CHANGED
@@ -8,7 +8,7 @@ export const HELP = `beliq: validate, generate, parse, and convert EU e-invoices
8
8
 
9
9
  Usage:
10
10
  beliq validate <file|dir|-> [<file|dir> ...] [--format auto|cii|ubl] [--fail-on error|warning] [--france-ctc] [--content-type <type>] [--json]
11
- beliq generate <invoice.json|-> --standard xrechnung|zugferd|facturx|peppol-bis [--pdf] [--facturx-profile basicwl|en16931|extended|extended-ctc-fr] [--no-verify] [--seal] [--output <file>] [--json]
11
+ beliq generate <invoice.json|-> --standard xrechnung|zugferd|facturx|peppol-bis|fatturapa|facturae|eslog|ksef [--pdf] [--facturx-profile basicwl|en16931|extended|extended-ctc-fr] [--no-verify] [--seal] [--output <file>] [--json]
12
12
  beliq parse <file|-> [--format auto|cii|ubl] [--content-type <type>] [--json]
13
13
  beliq convert <file|-> --target-format cii|ubl|zugferd|facturx|xrechnung|peppol-bis [--source-format auto|cii|ubl|zugferd|facturx|xrechnung|peppol-bis] [--target-profile basicwl|en16931|extended|extended-ctc-fr] [--content-type <type>] [--output <file>] [--json]
14
14
  beliq me [--json]
@@ -16,8 +16,16 @@ Usage:
16
16
  validate accepts an XML document or a ZUGFeRD/Factur-X PDF. Pass several files,
17
17
  a shell glob, or a directory (its .xml/.pdf files, recursively) to validate a
18
18
  batch: a per-file verdict, a summary, and one exit code for the whole run. A
19
- directory is a batch even when it holds one file. validate --france-ctc also
20
- applies the French CTC rules (BR-FR-CTC).
19
+ directory is a batch even when it holds one file. For a PDF the verdict is about
20
+ the embedded XML; the PDF itself is not checked.
21
+
22
+ Each verdict carries the check depth as the API states it, or "check depth not
23
+ stated" where the API states none. What the depths mean:
24
+ https://docs.beliq.eu/compliance/how-verification-works/
25
+
26
+ validate --france-ctc also applies the French CTC rules (BR-FR-CTC). A result
27
+ that carries France CTC blocking rule ids fails the file with exit 1 under
28
+ every --fail-on, also where the verdict is VALID.
21
29
 
22
30
  generate --facturx-profile applies to zugferd and facturx; extended-ctc-fr is
23
31
  Factur-X only.
@@ -25,6 +33,9 @@ Factur-X only.
25
33
  generate --seal also returns the document sha256 and validation verdict, so you
26
34
  can prove which rules the document passed; hashing the output reproduces it.
27
35
 
36
+ convert returns XML for every target, facturx and zugferd included. A PDF comes
37
+ back only when a hybrid PDF source already is the target, and needs --output.
38
+
28
39
  --pdf gives a hybrid PDF/A-3 with the XML embedded for zugferd and facturx.
29
40
  xrechnung and peppol-bis have no hybrid form, so --pdf returns a visualization
30
41
  with no XML inside it, and their legal document stays the XML.
@@ -42,13 +53,15 @@ Global options:
42
53
 
43
54
  Exit codes:
44
55
  0 success, or a valid document
45
- 1 document invalid (validate, per --fail-on)
56
+ 1 document invalid (validate, per --fail-on), or France CTC blocking rule
57
+ ids in the result
46
58
  2 usage error
47
59
  3 beliq API error, the API could not be reached, or an unexpected error
48
60
  4 I/O error
49
61
 
50
62
  In batch mode the code covers the whole run: 0 if every file passes, 1 if a
51
- document fails --fail-on, 3 if any file could not be checked.
63
+ document fails --fail-on or carries France CTC blocking rule ids, 3 if any file
64
+ could not be checked.
52
65
 
53
66
  A file argument of "-" reads from stdin. Set BELIQ_API_KEY (or pass --api-key)
54
67
  with a key from the beliq dashboard; the free tier is enough to evaluate.
@@ -0,0 +1,87 @@
1
+ export const VALIDATION_FIELDS = {
2
+ valid: 'text',
3
+ verified: 'text',
4
+ format: 'text',
5
+ profileDetected: 'text',
6
+ verificationBadgeLabel: 'text',
7
+ schematronVersion: 'text',
8
+ franceCtcBlockingRuleIds: 'text',
9
+ rulesetChannel: 'text',
10
+ rulesetFellBack: 'text',
11
+ rulesetSunsetsOn: 'text',
12
+ pdfInput: 'text',
13
+ errors: 'text',
14
+ warnings: 'text',
15
+ // The label above is the display form of these two ids.
16
+ verificationTier: 'json',
17
+ verificationBadge: 'json',
18
+ sha256: 'json',
19
+ rulesetSha256: 'json',
20
+ rulesetArtifacts: 'json',
21
+ driftCheckedAt: 'json',
22
+ ciusVersion: 'json',
23
+ peppolVersion: 'json',
24
+ facturxProfile: 'json',
25
+ facturxVersion: 'json',
26
+ franceCtcApplied: 'json',
27
+ franceCtcVersion: 'json',
28
+ franceExtendedCtcApplied: 'json',
29
+ italyFatturapaXsdBundle: 'json',
30
+ italyFatturapaRuntimeVersion: 'json',
31
+ italySdiMessaggiXsdBundle: 'json',
32
+ italySdiMessaggiXsdVersion: 'json',
33
+ spainFacturaeXsdBundle: 'json',
34
+ spainFacturaeRuntimeVersion: 'json',
35
+ sloveniaEslogXsdBundle: 'json',
36
+ sloveniaEslogRuntimeVersion: 'json',
37
+ polandKsefFa3XsdBundle: 'json',
38
+ polandKsefFa3RuntimeVersion: 'json',
39
+ romaniaRoCiusVersion: 'json',
40
+ netherlandsNlciusVersion: 'json',
41
+ };
42
+ export const PARSE_FIELDS = {
43
+ format: 'text',
44
+ profileDetected: 'text',
45
+ invoice: 'text',
46
+ warnings: 'text',
47
+ profileUrn: 'json',
48
+ businessProcessId: 'json',
49
+ franceCtcDetected: 'json',
50
+ };
51
+ /**
52
+ * Generate and convert build their --json object field by field, so a field
53
+ * can also be left out. `document` is the output itself, and `seal` fields are
54
+ * printed with --seal only.
55
+ */
56
+ export const GENERATE_FIELDS = {
57
+ bytes: 'document',
58
+ xml: 'document',
59
+ contentType: 'json',
60
+ sha256: 'seal',
61
+ // The summary reads the check depth from it; the whole object is printed with --seal.
62
+ validationResult: 'text',
63
+ meta: 'json',
64
+ };
65
+ export const GENERATE_META_FIELDS = {
66
+ schematronVersion: 'text',
67
+ livemode: 'text',
68
+ pdfKind: 'text',
69
+ outputEnvelope: 'json',
70
+ rulesetSha256: 'seal',
71
+ // With --seal the verdict object carries the same list.
72
+ rulesetArtifacts: 'seal',
73
+ };
74
+ export const CONVERT_FIELDS = {
75
+ bytes: 'document',
76
+ contentType: 'json',
77
+ meta: 'json',
78
+ };
79
+ export const CONVERT_META_FIELDS = {
80
+ sourceFormat: 'text',
81
+ targetFormat: 'text',
82
+ lostElementsCount: 'text',
83
+ livemode: 'text',
84
+ profileDetected: 'json',
85
+ lostElements: 'json',
86
+ conversionTools: 'json',
87
+ };
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Printed where the API sent no badge label for a verdict. It names no depth,
3
+ * so it claims none.
4
+ */
5
+ export const DEPTH_NOT_STATED = 'check depth not stated';
6
+ /** The API's own entry in `warnings` that explains `franceCtcBlockingRuleIds`. */
7
+ const FRANCE_BLOCK_ENTRY = 'FRANCE_CTC_FINDINGS_BLOCK_TRANSMISSION';
8
+ /**
9
+ * The verdict in the API's own words. `verified: false` means no ruleset ran
10
+ * (generate with verify off), so neither word applies.
11
+ */
12
+ export function verdictWord(result) {
13
+ if (result.verified === false)
14
+ return 'NOT VALIDATED';
15
+ return result.valid ? 'VALID' : 'INVALID';
16
+ }
17
+ /** The badge label the API sent, printed as it came, or the mark for none. */
18
+ export function checkDepth(result) {
19
+ return result.verificationBadgeLabel ?? DEPTH_NOT_STATED;
20
+ }
21
+ /** The France CTC rule ids the API reports as ending a transmission; empty when it sent none. */
22
+ export function franceBlockingRuleIds(result) {
23
+ return result.franceCtcBlockingRuleIds ?? [];
24
+ }
25
+ /**
26
+ * The lines that explain a France CTC block: the API's own message for it when
27
+ * the result carries one, and the rule ids unless that message already names
28
+ * every one of them.
29
+ */
30
+ export function franceBlockLines(result) {
31
+ const ids = franceBlockingRuleIds(result);
32
+ if (ids.length === 0)
33
+ return [];
34
+ const entry = (result.warnings ?? []).find((w) => w.ruleId === FRANCE_BLOCK_ENTRY);
35
+ const idLine = `France CTC blocking rule ids: ${ids.join(', ')}`;
36
+ if (!entry)
37
+ return [idLine];
38
+ const named = new Set(entry.message.split(/[^A-Za-z0-9_-]+/));
39
+ return ids.every((id) => named.has(id)) ? [entry.message] : [entry.message, idLine];
40
+ }
41
+ /**
42
+ * The embedded file's name comes from the uploaded PDF. Control and format
43
+ * characters, line separators and the double quote are replaced, so a crafted
44
+ * name can neither start a line of its own nor close the quotes around it.
45
+ */
46
+ function printable(name) {
47
+ return name.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}"]/gu, '?');
48
+ }
49
+ /** What the API says about a PDF input: which embedded file was read, and that the PDF was not checked. */
50
+ export function pdfInputLine(result) {
51
+ const pdf = result.pdfInput;
52
+ if (!pdf)
53
+ return undefined;
54
+ const name = pdf.attachmentName ? ` "${printable(pdf.attachmentName)}"` : '';
55
+ const others = pdf.attachmentCandidates > 1
56
+ ? ` ${pdf.attachmentCandidates} embedded files could be taken for the invoice, and nothing compared them.`
57
+ : '';
58
+ return (`PDF input: the verdict is about the embedded XML${name} (found by ${pdf.attachmentFoundBy}). ` +
59
+ `The PDF itself was not checked.${others}`);
60
+ }
61
+ /** Which ruleset channel the API says served the request, when the result names one. */
62
+ export function rulesetChannelLine(result) {
63
+ if (!result.rulesetChannel)
64
+ return undefined;
65
+ const fellBack = result.rulesetFellBack ? ', which had nothing to reach, so the latest ruleset ran' : '';
66
+ const sunset = result.rulesetSunsetsOn ? `; the retained ruleset is served until ${result.rulesetSunsetsOn}` : '';
67
+ return `Ruleset channel: ${result.rulesetChannel}${fellBack}${sunset}`;
68
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "beliq-cli",
3
- "version": "0.3.0",
4
- "description": "beliq CLI: validate, generate, parse, and convert EU e-invoices (XRechnung, ZUGFeRD, Factur-X, Peppol BIS) against authority-pinned, drift-checked rules, from the terminal or CI.",
3
+ "version": "0.4.0",
4
+ "description": "beliq CLI: validate, generate, parse, and convert EU e-invoices (XRechnung, ZUGFeRD, Factur-X, Peppol BIS) from the terminal or CI. Every verdict states how deep its check went.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "author": {
@@ -48,23 +48,24 @@
48
48
  "build": "npm run clean && tsc",
49
49
  "dev": "tsc --watch",
50
50
  "start": "node dist/index.js",
51
- "typecheck": "tsc --noEmit",
52
- "lint": "eslint src",
53
- "lint:fix": "eslint src --fix",
51
+ "typecheck": "tsc -p tsconfig.test.json",
52
+ "lint": "eslint . --max-warnings 0",
53
+ "lint:fix": "eslint . --fix",
54
54
  "test": "vitest run --exclude test/integration.test.ts",
55
55
  "test:integration": "vitest run test/integration.test.ts",
56
56
  "scrub:check": "bash scripts/scrub-check.sh",
57
57
  "prepublishOnly": "npm run build"
58
58
  },
59
59
  "dependencies": {
60
- "@beliq/sdk": "^0.4.0"
60
+ "@beliq/sdk": "^0.5.1"
61
61
  },
62
62
  "devDependencies": {
63
+ "@eslint/js": "^10.0.1",
63
64
  "@types/node": "^20.14.0",
64
- "@typescript-eslint/eslint-plugin": "^8.0.0",
65
- "@typescript-eslint/parser": "^8.0.0",
66
- "eslint": "^8.57.0",
65
+ "eslint": "^10.11.0",
66
+ "globals": "^17.12.0",
67
67
  "typescript": "^5.5.4",
68
+ "typescript-eslint": "^8.70.1",
68
69
  "vitest": "^4.0.0"
69
70
  }
70
71
  }