beliq-cli 0.2.2 → 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 +42 -14
- package/dist/args.js +2 -2
- package/dist/cli.js +31 -4
- package/dist/commands/convert.js +28 -11
- package/dist/commands/generate.js +24 -13
- package/dist/commands/parse.js +1 -1
- package/dist/commands/validate.js +22 -10
- package/dist/config.js +5 -2
- package/dist/exit.js +14 -3
- package/dist/format/parse.js +15 -4
- package/dist/format/validate.js +39 -14
- package/dist/help.js +29 -8
- package/dist/index.js +2 -1
- package/dist/resultFields.js +87 -0
- package/dist/verdict.js +68 -0
- package/examples/invalid-xrechnung.xml +2 -2
- package/package.json +10 -9
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,
|
|
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
|
|
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
|
|
|
@@ -17,16 +17,38 @@ Requires Node.js >= 20.15.
|
|
|
17
17
|
## Usage
|
|
18
18
|
|
|
19
19
|
```
|
|
20
|
-
beliq validate <file|dir|-> [<file|dir> ...] [--format auto|cii|ubl] [--fail-on error|warning] [--json]
|
|
21
|
-
beliq generate <invoice.json|-> --standard xrechnung|zugferd|facturx|peppol-bis [--pdf] [--facturx-profile
|
|
22
|
-
beliq parse <file|-> [--format auto|cii|ubl] [--json]
|
|
23
|
-
beliq convert <file|-> --target-format cii|ubl|zugferd|facturx|xrechnung|peppol-bis [--source-format
|
|
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|fatturapa|facturae|eslog|ksef [--pdf] [--facturx-profile basicwl|en16931|extended|extended-ctc-fr] [--no-verify] [--seal] [--output <file>] [--json]
|
|
22
|
+
beliq parse <file|-> [--format auto|cii|ubl] [--content-type <type>] [--json]
|
|
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]
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
+
`--facturx-profile` applies to `zugferd` and `facturx`; `extended-ctc-fr` is Factur-X only. `generate --seal` also returns the document's sha256 and its validation verdict, so you can prove which rules the document passed. `--content-type` sets the input's media type for `validate`, `parse` and `convert`; without it, input starting with `%PDF-` is sent as `application/pdf` and anything else as `application/xml`.
|
|
28
|
+
|
|
27
29
|
`generate --pdf` returns a hybrid PDF/A-3 with the XML embedded for `zugferd` and `facturx`. `xrechnung` and `peppol-bis` have no hybrid form, so `--pdf` returns a visualization with no XML inside it, and their legal document stays the XML.
|
|
28
30
|
|
|
29
|
-
`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. `--json` prints the raw API result (a report object in batch mode) as the only thing on stdout, so it pipes cleanly.
|
|
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
|
+
|
|
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.
|
|
30
52
|
|
|
31
53
|
```bash
|
|
32
54
|
# Validate a file, human-readable
|
|
@@ -41,8 +63,10 @@ beliq validate ./invoices
|
|
|
41
63
|
# Validate from a pipe, machine-readable, fail the shell on any warning too
|
|
42
64
|
cat invoice.xml | beliq validate - --json --fail-on warning
|
|
43
65
|
|
|
44
|
-
# Generate an XRechnung from
|
|
45
|
-
|
|
66
|
+
# Generate an XRechnung from the example invoice, write the XML to stdout.
|
|
67
|
+
# The example ships in the npm package; in a clone of this repository it is
|
|
68
|
+
# examples/invoice.json.
|
|
69
|
+
beliq generate "$(npm root -g)/beliq-cli/examples/invoice.json" --standard xrechnung > invoice.xml
|
|
46
70
|
|
|
47
71
|
# Convert a CII document to a Peppol BIS UBL document
|
|
48
72
|
beliq convert invoice.xml --target-format peppol-bis --output peppol.xml
|
|
@@ -65,16 +89,18 @@ The exit code is the contract that makes it useful in scripts and CI:
|
|
|
65
89
|
| Code | Meaning |
|
|
66
90
|
|---|---|
|
|
67
91
|
| 0 | success, or a valid document |
|
|
68
|
-
| 1 | document invalid (`validate`, per `--fail-on`) |
|
|
92
|
+
| 1 | document invalid (`validate`, per `--fail-on`), or a result that carries `franceCtcBlockingRuleIds` |
|
|
69
93
|
| 2 | usage error (bad flag, missing argument, missing API key, PDF without `--output`) |
|
|
70
|
-
| 3 | beliq API error (bad key, quota, engine, a rejected document) |
|
|
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. |
|
|
71
95
|
| 4 | I/O error (unreadable input, or an output path that already exists) |
|
|
72
96
|
|
|
73
|
-
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).
|
|
98
|
+
|
|
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" } }`.
|
|
74
100
|
|
|
75
101
|
## In CI
|
|
76
102
|
|
|
77
|
-
Validate every invoice a build produces and fail on
|
|
103
|
+
Validate every invoice a build produces and fail on an invalid one:
|
|
78
104
|
|
|
79
105
|
```yaml
|
|
80
106
|
- run: npx beliq-cli validate dist/invoices --fail-on error
|
|
@@ -82,13 +108,15 @@ Validate every invoice a build produces and fail on a non-compliant one:
|
|
|
82
108
|
BELIQ_API_KEY: ${{ secrets.BELIQ_API_KEY }}
|
|
83
109
|
```
|
|
84
110
|
|
|
85
|
-
`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
|
|
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`.
|
|
86
112
|
|
|
87
113
|
## Development
|
|
88
114
|
|
|
89
115
|
```bash
|
|
90
116
|
npm install
|
|
91
117
|
npm run build
|
|
118
|
+
npm run typecheck # src and tests
|
|
119
|
+
npm run lint # ESLint with type information, whole repository
|
|
92
120
|
npm test # unit tests, no network
|
|
93
121
|
npm run scrub:check # no em-dash
|
|
94
122
|
BELIQ_API_KEY=... npm run test:integration # live smoke against the real API
|
package/dist/args.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { UsageError } from './errors.js';
|
|
2
2
|
/** Flags that take no value. */
|
|
3
|
-
const BOOLEAN_FLAGS = new Set(['json', 'pdf', 'no-verify', 'france-ctc', 'seal']);
|
|
3
|
+
export const BOOLEAN_FLAGS = new Set(['json', 'pdf', 'no-verify', 'france-ctc', 'seal']);
|
|
4
4
|
/** Flags that take a value (`--flag value` or `--flag=value`). */
|
|
5
|
-
const VALUE_FLAGS = new Set([
|
|
5
|
+
export const VALUE_FLAGS = new Set([
|
|
6
6
|
'format',
|
|
7
7
|
'target-format',
|
|
8
8
|
'source-format',
|
package/dist/cli.js
CHANGED
|
@@ -4,6 +4,7 @@ import { resolveConfig } from './config.js';
|
|
|
4
4
|
import { EXIT } from './exit.js';
|
|
5
5
|
import { IoError, UsageError } from './errors.js';
|
|
6
6
|
import { HELP, version } from './help.js';
|
|
7
|
+
import { renderValidationHuman } from './format/validate.js';
|
|
7
8
|
import { runValidate } from './commands/validate.js';
|
|
8
9
|
import { runGenerate } from './commands/generate.js';
|
|
9
10
|
import { runParse } from './commands/parse.js';
|
|
@@ -63,11 +64,37 @@ export async function main(argv, io, env = process.env) {
|
|
|
63
64
|
return EXIT.IO;
|
|
64
65
|
}
|
|
65
66
|
if (err instanceof BeliqApiError) {
|
|
66
|
-
|
|
67
|
-
io.stderr(`beliq: API error ${err.status}${code}: ${err.message}`);
|
|
67
|
+
reportApiError(err, args, io);
|
|
68
68
|
return EXIT.API;
|
|
69
69
|
}
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
// Mostly a transport failure (DNS, a refused connection, TLS), which the SDK
|
|
71
|
+
// rethrows as fetch's own TypeError. None of these is a verdict on the
|
|
72
|
+
// document, and exit 1 is the "document invalid" code a CI gate reads.
|
|
73
|
+
io.stderr(`beliq: unexpected error: ${describeError(err)}`);
|
|
74
|
+
return EXIT.API;
|
|
72
75
|
}
|
|
73
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* An API error on stderr, followed by the rules a rejected document failed when
|
|
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.
|
|
83
|
+
*/
|
|
84
|
+
function reportApiError(err, args, io) {
|
|
85
|
+
const code = err.code ? ` (${err.code})` : '';
|
|
86
|
+
io.stderr(`beliq: API error ${err.status}${code}: ${err.message}`);
|
|
87
|
+
const result = err.details?.validationResult;
|
|
88
|
+
if (result && typeof result === 'object')
|
|
89
|
+
io.stderr(renderValidationHuman(result));
|
|
90
|
+
if (args.flags.json === true) {
|
|
91
|
+
const error = { status: err.status, code: err.code, message: err.message, details: err.details };
|
|
92
|
+
io.stdout(`${JSON.stringify({ error }, null, 2)}\n`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** The message plus its cause: fetch reports only "fetch failed" and keeps the reason in `cause`. */
|
|
96
|
+
function describeError(err) {
|
|
97
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
98
|
+
const cause = err instanceof Error && err.cause instanceof Error ? err.cause.message : undefined;
|
|
99
|
+
return cause ? `${message} (${cause})` : message;
|
|
100
|
+
}
|
package/dist/commands/convert.js
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
|
-
import { LIVE_CONVERT_SOURCE_FORMATS, LIVE_CONVERT_TARGET_FORMATS, } from '@beliq/sdk';
|
|
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
|
-
/**
|
|
6
|
-
const
|
|
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.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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>');
|
|
@@ -17,19 +21,30 @@ export async function runConvert(args, deps, io) {
|
|
|
17
21
|
throw new UsageError(`--target-format is required (one of: ${LIVE_CONVERT_TARGET_FORMATS.join(', ')})`);
|
|
18
22
|
}
|
|
19
23
|
const sourceFormat = oneOf(flagStr(args, 'source-format'), LIVE_CONVERT_SOURCE_FORMATS, 'source-format');
|
|
24
|
+
// The flat list, not the per-standard one generate checks: the engine's
|
|
25
|
+
// convert route resolves a profile the same way for both hybrid targets, and
|
|
26
|
+
// the API ignores it for the others.
|
|
27
|
+
const targetProfile = oneOf(flagStr(args, 'target-profile'), LIVE_PROFILES, 'target-profile');
|
|
20
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
|
+
}
|
|
21
32
|
const result = await deps.client.convert(bytes, {
|
|
22
33
|
targetFormat: targetFormat,
|
|
23
34
|
sourceFormat: sourceFormat,
|
|
24
|
-
targetProfile:
|
|
35
|
+
targetProfile: targetProfile,
|
|
25
36
|
contentType: flagStr(args, 'content-type'),
|
|
26
37
|
});
|
|
27
|
-
const kind =
|
|
38
|
+
const kind = result.contentType.includes('pdf') ? 'pdf' : 'xml';
|
|
28
39
|
const from = result.meta.sourceFormat ? `${result.meta.sourceFormat} ` : '';
|
|
29
40
|
const to = result.meta.targetFormat ?? targetFormat;
|
|
30
41
|
const lost = result.meta.lostElementsCount && result.meta.lostElementsCount > 0
|
|
31
|
-
? ` ${result.meta.lostElementsCount} element(s)
|
|
42
|
+
? ` The engine recorded ${result.meta.lostElementsCount} source element(s) with no target equivalent.`
|
|
32
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.';
|
|
33
48
|
return emitDocument(io, args, {
|
|
34
49
|
kind,
|
|
35
50
|
bytes: result.bytes,
|
|
@@ -39,8 +54,10 @@ export async function runConvert(args, deps, io) {
|
|
|
39
54
|
profileDetected: result.meta.profileDetected,
|
|
40
55
|
lostElementsCount: result.meta.lostElementsCount,
|
|
41
56
|
lostElements: result.meta.lostElements,
|
|
57
|
+
conversionTools: result.meta.conversionTools,
|
|
58
|
+
livemode: result.meta.livemode,
|
|
42
59
|
contentType: result.contentType,
|
|
43
60
|
},
|
|
44
|
-
summary: `Converted ${from}to ${to}.${lost}`,
|
|
61
|
+
summary: `Converted ${from}to ${to}, returned as ${kind === 'pdf' ? 'a PDF' : 'XML'}${sandbox}.${checked}${lost}`,
|
|
45
62
|
});
|
|
46
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
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
|
|
49
|
-
|
|
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 =
|
|
65
|
-
|
|
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
|
}
|
package/dist/commands/parse.js
CHANGED
|
@@ -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,17 +8,22 @@ 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
|
|
11
|
+
* Validate one or many documents. Each verdict names how deep its check went.
|
|
12
12
|
*
|
|
13
|
-
* One
|
|
14
|
-
*
|
|
15
|
-
* batch: a per-file verdict, an aggregate summary, and a batch exit code (0
|
|
16
|
-
* pass, 1 some document fails --fail-on
|
|
13
|
+
* One file (or `-`) named on its own keeps the classic per-document output.
|
|
14
|
+
* Multiple inputs, or a directory (its `.xml`/`.pdf` files, recursively), switch
|
|
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 or carries France CTC blocking rule
|
|
17
|
+
* ids, 3 some file could not be checked).
|
|
18
|
+
* A directory stays a batch even when it holds one invoice, so the --json shape
|
|
19
|
+
* follows what was asked for rather than how many files were found.
|
|
17
20
|
*/
|
|
18
21
|
export async function runValidate(args, deps, io) {
|
|
19
22
|
const format = oneOf(flagStr(args, 'format'), LIVE_VALIDATE_FORMATS, 'format');
|
|
20
23
|
const failOn = (oneOf(flagStr(args, 'fail-on'), FAIL_ON, 'fail-on') ?? 'error');
|
|
21
24
|
const contentType = flagStr(args, 'content-type');
|
|
25
|
+
// Sent only when set: the API's own default decides otherwise.
|
|
26
|
+
const franceCtc = flagBool(args, 'france-ctc') ? true : undefined;
|
|
22
27
|
const json = flagBool(args, 'json');
|
|
23
28
|
if (args.positionals.length === 0) {
|
|
24
29
|
throw new UsageError('missing file argument. Usage: beliq validate <file|dir|-> [<file|dir> ...]');
|
|
@@ -30,19 +35,26 @@ export async function runValidate(args, deps, io) {
|
|
|
30
35
|
if (files.length > 1 && files.includes('-')) {
|
|
31
36
|
throw new UsageError('cannot mix "-" (stdin) with other inputs');
|
|
32
37
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
38
|
+
// expandInputs returns a named file as given and a directory as the files
|
|
39
|
+
// inside it, so a single input that comes back unchanged was a file.
|
|
40
|
+
const single = args.positionals.length === 1 && files.length === 1 && files[0] === args.positionals[0];
|
|
41
|
+
if (single) {
|
|
42
|
+
const result = await deps.client.validate(await io.readInput(files[0]), { format, contentType, franceCtc });
|
|
43
|
+
io.stdout(json ? `${JSON.stringify(result, null, 2)}\n` : `${renderValidationHuman(result, { franceFailsFile: true })}\n`);
|
|
36
44
|
return computeExitCode(result, failOn);
|
|
37
45
|
}
|
|
38
|
-
return runBatch(files, { format, contentType, failOn, json }, deps, io);
|
|
46
|
+
return runBatch(files, { format, contentType, franceCtc, failOn, json }, deps, io);
|
|
39
47
|
}
|
|
40
48
|
async function runBatch(files, opts, deps, io) {
|
|
41
49
|
const rows = [];
|
|
42
50
|
for (const file of files) {
|
|
43
51
|
try {
|
|
44
52
|
const bytes = await io.readInput(file);
|
|
45
|
-
const result = await deps.client.validate(bytes, {
|
|
53
|
+
const result = await deps.client.validate(bytes, {
|
|
54
|
+
format: opts.format,
|
|
55
|
+
contentType: opts.contentType,
|
|
56
|
+
franceCtc: opts.franceCtc,
|
|
57
|
+
});
|
|
46
58
|
rows.push({ file, result, fails: computeExitCode(result, opts.failOn) !== EXIT.OK });
|
|
47
59
|
}
|
|
48
60
|
catch (err) {
|
package/dist/config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { DEFAULT_BASE_URL } from '@beliq/sdk';
|
|
2
|
-
import { flagStr } from './args.js';
|
|
2
|
+
import { flagStr, oneOf } from './args.js';
|
|
3
3
|
import { UsageError } from './errors.js';
|
|
4
|
+
const AUTH_MODES = ['header', 'bearer'];
|
|
4
5
|
/**
|
|
5
6
|
* Resolve the typed config with precedence flag > env > default. Throws a
|
|
6
7
|
* UsageError if no API key is available so the CLI reports a clean usage error
|
|
@@ -13,7 +14,9 @@ export function resolveConfig(args, env = process.env) {
|
|
|
13
14
|
throw new UsageError('no API key. Set BELIQ_API_KEY or pass --api-key. Create a key in the beliq dashboard under API Keys.');
|
|
14
15
|
}
|
|
15
16
|
const baseUrl = (flagStr(args, 'base-url') ?? env.BELIQ_BASE_URL ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
17
|
+
// An unknown mode is refused rather than read as `header`: a typo in
|
|
18
|
+
// `bearer` would otherwise send the key a way the caller did not ask for.
|
|
16
19
|
const authRaw = (flagStr(args, 'auth') ?? env.BELIQ_AUTH ?? '').trim().toLowerCase();
|
|
17
|
-
const auth = authRaw
|
|
20
|
+
const auth = oneOf(authRaw || undefined, AUTH_MODES, 'auth (or BELIQ_AUTH)', 'header');
|
|
18
21
|
return { apiKey, baseUrl, auth };
|
|
19
22
|
}
|
package/dist/exit.js
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
export const EXIT = {
|
|
2
2
|
/** Success, or a document that passed validation. */
|
|
3
3
|
OK: 0,
|
|
4
|
-
/**
|
|
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,
|
|
8
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* The beliq API returned an error (bad key, quota, engine, a rejected
|
|
13
|
+
* document), could not be reached, or something unexpected failed. Never a
|
|
14
|
+
* verdict on the document.
|
|
15
|
+
*/
|
|
9
16
|
API: 3,
|
|
10
17
|
/** A local I/O error: an unreadable input or an output path that already exists. */
|
|
11
18
|
IO: 4,
|
|
@@ -13,11 +20,15 @@ export const EXIT = {
|
|
|
13
20
|
/**
|
|
14
21
|
* The CI contract: EXIT.OK when the document passes the chosen threshold,
|
|
15
22
|
* EXIT.INVALID otherwise. `error` (default) fails on any error; `warning` also
|
|
16
|
-
* 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.
|
|
17
26
|
*/
|
|
18
27
|
export function computeExitCode(result, failOn) {
|
|
19
28
|
const errorCount = result.errors?.length ?? 0;
|
|
20
29
|
const warningCount = result.warnings?.length ?? 0;
|
|
30
|
+
if ((result.franceCtcBlockingRuleIds?.length ?? 0) > 0)
|
|
31
|
+
return EXIT.INVALID;
|
|
21
32
|
const failsOnError = !result.valid || errorCount > 0;
|
|
22
33
|
if (failOn === 'warning')
|
|
23
34
|
return failsOnError || warningCount > 0 ? EXIT.INVALID : EXIT.OK;
|
package/dist/format/parse.js
CHANGED
|
@@ -2,9 +2,10 @@ function plural(n, word) {
|
|
|
2
2
|
return `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
3
3
|
}
|
|
4
4
|
/**
|
|
5
|
-
* A
|
|
6
|
-
*
|
|
7
|
-
* invoice
|
|
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
|
-
|
|
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
|
}
|
package/dist/format/validate.js
CHANGED
|
@@ -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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
|
|
26
|
-
const
|
|
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
|
|
47
|
+
return top;
|
|
31
48
|
const rows = issues.map((i) => [i.severity, i.ruleId, i.location || '-', i.message]);
|
|
32
|
-
return `${
|
|
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
|
|
37
|
-
*
|
|
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
|
|
78
|
+
return [table, ...(blocked.length > 0 ? [blocked.join('\n')] : []), summary].join('\n\n');
|
|
54
79
|
}
|
package/dist/help.js
CHANGED
|
@@ -7,19 +7,35 @@ export function version() {
|
|
|
7
7
|
export const HELP = `beliq: validate, generate, parse, and convert EU e-invoices
|
|
8
8
|
|
|
9
9
|
Usage:
|
|
10
|
-
beliq validate <file|dir|-> [<file|dir> ...] [--format auto|cii|ubl] [--fail-on error|warning] [--json]
|
|
11
|
-
beliq generate <invoice.json|-> --standard xrechnung|zugferd|facturx|peppol-bis [--pdf] [--facturx-profile
|
|
12
|
-
beliq parse <file|-> [--format auto|cii|ubl] [--json]
|
|
13
|
-
beliq convert <file|-> --target-format cii|ubl|zugferd|facturx|xrechnung|peppol-bis [--source-format
|
|
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|fatturapa|facturae|eslog|ksef [--pdf] [--facturx-profile basicwl|en16931|extended|extended-ctc-fr] [--no-verify] [--seal] [--output <file>] [--json]
|
|
12
|
+
beliq parse <file|-> [--format auto|cii|ubl] [--content-type <type>] [--json]
|
|
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]
|
|
15
15
|
|
|
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
|
-
batch: a per-file verdict, a summary, and one exit code for the whole run.
|
|
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. 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.
|
|
29
|
+
|
|
30
|
+
generate --facturx-profile applies to zugferd and facturx; extended-ctc-fr is
|
|
31
|
+
Factur-X only.
|
|
19
32
|
|
|
20
33
|
generate --seal also returns the document sha256 and validation verdict, so you
|
|
21
34
|
can prove which rules the document passed; hashing the output reproduces it.
|
|
22
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
|
+
|
|
23
39
|
--pdf gives a hybrid PDF/A-3 with the XML embedded for zugferd and facturx.
|
|
24
40
|
xrechnung and peppol-bis have no hybrid form, so --pdf returns a visualization
|
|
25
41
|
with no XML inside it, and their legal document stays the XML.
|
|
@@ -29,18 +45,23 @@ Global options:
|
|
|
29
45
|
--base-url <url> API base URL (default: BELIQ_BASE_URL, else https://api.beliq.eu)
|
|
30
46
|
--auth <mode> header (default, X-API-Key) or bearer
|
|
31
47
|
--json machine-readable JSON on stdout
|
|
48
|
+
--content-type <type>
|
|
49
|
+
the input's media type (default: application/pdf when the
|
|
50
|
+
bytes start with %PDF-, else application/xml)
|
|
32
51
|
-h, --help show this help
|
|
33
52
|
-v, --version show the version
|
|
34
53
|
|
|
35
54
|
Exit codes:
|
|
36
55
|
0 success, or a valid document
|
|
37
|
-
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
|
|
38
58
|
2 usage error
|
|
39
|
-
3 beliq API error
|
|
59
|
+
3 beliq API error, the API could not be reached, or an unexpected error
|
|
40
60
|
4 I/O error
|
|
41
61
|
|
|
42
62
|
In batch mode the code covers the whole run: 0 if every file passes, 1 if a
|
|
43
|
-
document fails --fail-on, 3 if any file
|
|
63
|
+
document fails --fail-on or carries France CTC blocking rule ids, 3 if any file
|
|
64
|
+
could not be checked.
|
|
44
65
|
|
|
45
66
|
A file argument of "-" reads from stdin. Set BELIQ_API_KEY (or pass --api-key)
|
|
46
67
|
with a key from the beliq dashboard; the free tier is enough to evaluate.
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { main } from './cli.js';
|
|
3
|
+
import { EXIT } from './exit.js';
|
|
3
4
|
import { nodeIO } from './io.js';
|
|
4
5
|
main(process.argv.slice(2), nodeIO()).then((code) => {
|
|
5
6
|
process.exitCode = code;
|
|
6
7
|
}, (err) => {
|
|
7
8
|
process.stderr.write(`beliq: fatal: ${err.message}\n`);
|
|
8
|
-
process.exitCode =
|
|
9
|
+
process.exitCode = EXIT.API;
|
|
9
10
|
});
|
|
@@ -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
|
+
};
|
package/dist/verdict.js
ADDED
|
@@ -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
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
2
|
<!-- A minimal CII (UN/CEFACT Cross Industry Invoice) carrying the XRechnung
|
|
3
3
|
guideline id but missing the Buyer reference (BT-10), which XRechnung
|
|
4
|
-
requires (rule BR-DE-15). Used as
|
|
5
|
-
live test
|
|
4
|
+
requires (rule BR-DE-15). Used as the known-invalid example in the gated
|
|
5
|
+
live test. -->
|
|
6
6
|
<rsm:CrossIndustryInvoice
|
|
7
7
|
xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
|
|
8
8
|
xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "beliq-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "beliq CLI: validate, generate, parse, and convert EU e-invoices (XRechnung, ZUGFeRD, Factur-X, Peppol BIS)
|
|
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
|
|
52
|
-
"lint": "eslint
|
|
53
|
-
"lint:fix": "eslint
|
|
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.
|
|
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
|
-
"
|
|
65
|
-
"
|
|
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
|
}
|