@readystack/einvoice-lint-en16931 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.txt ADDED
@@ -0,0 +1,2 @@
1
+ Copyright. Free features may be used without a licence key.
2
+ Paid features require a valid licence key.
package/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # E-Invoice Lint for XRechnung, Factur-X and UBL
2
+
3
+ ![E-Invoice Lint for XRechnung, Factur-X and UBL](https://getreadystack.com/img/promo/sku14679_result_card.jpg)
4
+
5
+ Catches retired profile identifiers, code-list values outside EN 16931 and malformed dates in your e-invoice XML, in the editor, before an access point rejects the file.
6
+
7
+ ## Install
8
+
9
+ ```
10
+ npx @readystack/einvoice-lint-en16931 file
11
+ ```
12
+
13
+ Node 18+. The same 26 rules as the VS Code extension, from a terminal or CI.
14
+
15
+ ## Free
16
+
17
+ - Check the open e-invoice against all 26 rules; Check only the lines you select; Reopen the last findings, with line numbers and severity; Read every rule that ships inside, in plain English
18
+ - `--rules` lists every rule
19
+
20
+ ## With a licence ($29 once)
21
+
22
+ - Check every e-invoice file in the repository; Findings report as CSV, JSON or HTML; JSON output a build step can fail on
23
+
24
+ ```
25
+ @readystack/einvoice-lint-en16931 --dir ./templates --report html --out report.html
26
+ ```
27
+
28
+ Peppol access points bill per document - published pay-per-use rates run EUR 0.18 to EUR 0.25 per invoice - and every rejected document is one you send twice.
29
+
30
+ 7-day refund, no questions. Set `READYSTACK_LICENSE=<key>` or run `--license <key>` once.
31
+
32
+ [Get a licence](https://buy.polar.sh/polar_cl_uvaD8HrNLY041vZaDJqCklAVjAZRz5CnK5b184gKfFx)
33
+
34
+
35
+ <!-- e invoice lint -->
package/cli.js ADDED
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ // cli.js — the same rules as the VS Code extension, run from a terminal or a container. Generated by adapters.py.
3
+ 'use strict';
4
+ const fs = require('fs'), path = require('path');
5
+ const RULES = require('./rules.json');
6
+ const S = require('./strings.json');
7
+ const lic = require('./license.js');
8
+ function scan(text) {
9
+ const lines = String(text).split(/\r?\n/); const hits = [];
10
+ for (let i = 0; i < lines.length; i++) {
11
+ for (const r of RULES) {
12
+ let re; try { re = new RegExp(r.pattern, r.flags || ''); } catch (e) { continue; }
13
+ if (re.test(lines[i])) hits.push({ line: i + 1, msg: r.message, fix: r.fix || null, sev: r.sev || 'warn' });
14
+ }
15
+ }
16
+ return hits;
17
+ }
18
+ function isText(p) { try { const b = fs.readFileSync(p); if (b.length > 2 * 1024 * 1024) return false; const s = b.subarray(0, 4096); for (const x of s) if (x === 0) return false; return true; } catch (e) { return false; } }
19
+ function walk(dir, exts, out) {
20
+ let ents = []; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return out; }
21
+ for (const e of ents) {
22
+ if (e.name === 'node_modules' || e.name === '.git' || e.name.startsWith('.')) continue;
23
+ const p = path.join(dir, e.name);
24
+ if (e.isDirectory()) walk(p, exts, out);
25
+ else if ((!exts.length || exts.includes(path.extname(e.name).toLowerCase())) && isText(p)) out.push(p);
26
+ }
27
+ return out;
28
+ }
29
+ function esc(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
30
+ function csvq(s) { return '"' + String(s == null ? '' : s).replace(/"/g, '""') + '"'; }
31
+ function render(rows, fmt) {
32
+ if (fmt === 'json') return JSON.stringify({ tool: S.name, rules: RULES.length, files: rows }, null, 1);
33
+ if (fmt === 'csv') { const o = ['file,line,severity,message,fix']; for (const r of rows) for (const h of r.hits) o.push([csvq(r.file), h.line, h.sev, csvq(h.msg), csvq(h.fix)].join(',')); return o.join('\n') + '\n'; }
34
+ if (fmt === 'html') {
35
+ const n = rows.reduce((a, r) => a + r.hits.length, 0);
36
+ let o = '<!doctype html><meta charset="utf-8"><title>' + esc(S.name) + ' report</title><style>body{font:14px system-ui;margin:24px}table{border-collapse:collapse}td,th{border:1px solid #ddd;padding:4px 8px;font-size:13px}code{font-family:ui-monospace,monospace}</style>';
37
+ o += '<h1>' + esc(S.name) + '</h1><p>' + rows.length + ' files · ' + n + ' findings · ' + RULES.length + ' rules</p><table><tr><th>file</th><th>line</th><th>sev</th><th>message</th><th>fix</th></tr>';
38
+ for (const r of rows) for (const h of r.hits) o += '<tr><td><code>' + esc(r.file) + '</code></td><td>' + h.line + '</td><td>' + esc(h.sev) + '</td><td>' + esc(h.msg) + '</td><td>' + esc(h.fix || '') + '</td></tr>';
39
+ return o + '</table>';
40
+ }
41
+ let o = ''; for (const r of rows) { o += r.file + '\n'; for (const h of r.hits) o += ' ' + String(h.line).padStart(5) + ' ' + h.sev.padEnd(5) + ' ' + h.msg + (h.fix ? '\n -> ' + h.fix : '') + '\n'; if (!r.hits.length) o += ' (no findings)\n'; }
42
+ return o;
43
+ }
44
+ function help() {
45
+ return [S.name + ' - ' + S.subtitle, '', 'Usage: ' + S.bin + ' <file> [more files] check the files you name (free, every rule)',
46
+ ' ' + S.bin + ' --dir <folder> [--ext .html] scan a whole folder (licence)',
47
+ ' ' + S.bin + ' ... --report csv|json|html [--out file] export a report (licence)',
48
+ ' ' + S.bin + ' ... --ci exit 1 when an error-level finding exists (licence)',
49
+ ' ' + S.bin + ' --license <key> store your licence key (or set READYSTACK_LICENSE)',
50
+ ' ' + S.bin + ' --rules list the ' + RULES.length + ' rules', '',
51
+ 'Free: ' + S.free, 'Licence ($' + S.price + ', once, 7-day refund): ' + S.paid, 'Get a licence: ' + lic.BUY_URL, ''].join('\n');
52
+ }
53
+ (async function main() {
54
+ try { const _feed = await lic.pullFeed(); if (_feed && Array.isArray(_feed.rules)) { for (const r of _feed.rules) RULES.push(r); } } catch (e) {} // ★s134 구독 피드 병합 (키 있는 손님만)
55
+ const a = process.argv.slice(2);
56
+ const get = (k) => { const i = a.indexOf(k); return i >= 0 ? a[i + 1] : null; };
57
+ if (!a.length || a.includes('--help') || a.includes('-h')) { process.stdout.write(help()); return; }
58
+ if (a.includes('--rules')) { process.stdout.write(RULES.map((r, i) => String(i + 1).padStart(3) + ' [' + (r.sev || 'warn') + '] ' + r.message).join('\n') + '\n'); return; }
59
+ if (a.includes('--license')) { const r = await lic.ensure(get('--license')); process.stdout.write(r.ok ? 'Licence stored: ' + lic.storePath() + '\n' : 'Licence not accepted (' + r.why + '). Get one: ' + lic.BUY_URL + '\n'); process.exit(r.ok ? 0 : 2); }
60
+ const dir = get('--dir'), fmt = get('--report'), out = get('--out'), ci = a.includes('--ci');
61
+ const exts = a.includes('--ext') ? [get('--ext')] : (S.exts || []);
62
+ const paid = !!(dir || fmt || ci);
63
+ if (paid) {
64
+ const r = await lic.ensure();
65
+ if (!r.ok) { process.stderr.write(S.need_key + '\n set READYSTACK_LICENSE=<key> or ' + S.bin + ' --license <key>\n Get a licence ($' + S.price + ', once): ' + lic.BUY_URL + '\n'); process.exit(2); }
66
+ }
67
+ const files = dir ? walk(dir, exts, []) : a.filter((x, i) => !x.startsWith('--') && !['--dir', '--report', '--out', '--ext', '--license'].includes(a[i - 1]));
68
+ if (!files.length) { process.stderr.write('No files. ' + S.bin + ' --help\n'); process.exit(2); }
69
+ const rows = files.map((f) => { let t = ''; try { t = fs.readFileSync(f, 'utf8'); } catch (e) { return { file: f, hits: [], error: String(e.message) }; } return { file: f, hits: scan(t) }; });
70
+ const text = render(rows, fmt || 'text');
71
+ if (out) fs.writeFileSync(out, text); else process.stdout.write(text.endsWith('\n') ? text : text + '\n');
72
+ const errors = rows.reduce((n, r) => n + r.hits.filter((h) => h.sev === 'error').length, 0);
73
+ if (ci && errors) process.exit(1);
74
+ })().catch((e) => { process.stderr.write(String(e && e.stack || e) + '\n'); process.exit(3); });
package/license.js ADDED
@@ -0,0 +1,63 @@
1
+ // license.js — Polar licence check for the CLI / container. Generated by adapters.py; do not edit by hand.
2
+ 'use strict';
3
+ const https = require('https'), fs = require('fs'), path = require('path'), os = require('os');
4
+ const ORG_ID = 'a5cdf664-d8e7-4f87-8895-056717aaba17';
5
+ const BUY_URL = 'https://buy.polar.sh/polar_cl_uvaD8HrNLY041vZaDJqCklAVjAZRz5CnK5b184gKfFx';
6
+ const SLUG = 'einvoice-lint-en16931';
7
+ const GRACE_MS = 30 * 24 * 3600 * 1000; // after a successful check, 30 days work offline
8
+ const RECHECK_MS = 7 * 24 * 3600 * 1000; // re-ask Polar every 7 days (refunds / cancellations)
9
+ function storePath() { return path.join(process.env.READYSTACK_HOME || path.join(os.homedir(), '.config', 'readystack'), SLUG + '.json'); }
10
+ function load() { try { return JSON.parse(fs.readFileSync(storePath(), 'utf8')); } catch (e) { return {}; } }
11
+ function save(o) { try { fs.mkdirSync(path.dirname(storePath()), { recursive: true }); fs.writeFileSync(storePath(), JSON.stringify(o)); } catch (e) { /* read-only home: still works for this run */ } }
12
+ function validate(key) {
13
+ return new Promise(function (resolve) {
14
+ if (!ORG_ID) return resolve({ ok: false, offline: false });
15
+ const body = JSON.stringify({ key: key, organization_id: ORG_ID });
16
+ const req = https.request({ hostname: 'api.polar.sh', path: '/v1/customer-portal/license-keys/validate', method: 'POST', timeout: 8000,
17
+ headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) } }, function (res) {
18
+ let buf = ''; res.on('data', function (d) { buf += d; });
19
+ res.on('end', function () {
20
+ if (res.statusCode !== 200) return resolve({ ok: false, offline: false });
21
+ try { const j = JSON.parse(buf); resolve({ ok: j && (j.status === 'granted' || j.valid === true || !!j.id), offline: false }); }
22
+ catch (e) { resolve({ ok: false, offline: false }); }
23
+ });
24
+ });
25
+ req.on('timeout', function () { req.destroy(); resolve({ ok: false, offline: true }); });
26
+ req.on('error', function () { resolve({ ok: false, offline: true }); });
27
+ req.write(body); req.end();
28
+ });
29
+ }
30
+ async function ensure(explicitKey) {
31
+ const st = load();
32
+ const key = explicitKey || process.env.READYSTACK_LICENSE || st.key;
33
+ if (!key) return { ok: false, why: 'no_key' };
34
+ const age = Date.now() - (st.okAt || 0);
35
+ if (!explicitKey && st.key === key && age < RECHECK_MS) return { ok: true, cached: true };
36
+ const r = await validate(String(key).trim());
37
+ if (r.ok) { save({ key: String(key).trim(), okAt: Date.now() }); return { ok: true }; }
38
+ if (r.offline && st.key === key && age < GRACE_MS) return { ok: true, offline: true };
39
+ return { ok: false, why: r.offline ? 'offline' : 'invalid' };
40
+ }
41
+ // ★s134 — 구독 규칙 피드(층3 "바뀌면 업데이트"): 키 있는 손님만 · 7일마다 · 오프라인은 캐시. 워커 GET /api/rules/<slug>?key=
42
+ const FEED_URL = 'https://getreadystack.com/api/rules/';
43
+ function pullFeed() {
44
+ const st = load(); const key = process.env.READYSTACK_LICENSE || st.key; const cached = st.feed || null;
45
+ if (!key) return Promise.resolve(cached);
46
+ if (cached && (Date.now() - (st.feedAt || 0)) < RECHECK_MS) return Promise.resolve(cached);
47
+ return new Promise(function (resolve) {
48
+ let req;
49
+ try {
50
+ req = https.get(FEED_URL + encodeURIComponent(SLUG) + '?key=' + encodeURIComponent(key), { timeout: 8000, headers: { 'user-agent': 'readystack-cli' } }, function (res) {
51
+ let buf = ''; res.on('data', function (d) { buf += d; });
52
+ res.on('end', function () {
53
+ if (res.statusCode !== 200) return resolve(cached);
54
+ try { const j = JSON.parse(buf); if (!j || !Array.isArray(j.rules)) return resolve(cached); save(Object.assign(load(), { feed: j, feedAt: Date.now() })); resolve(j); }
55
+ catch (e) { resolve(cached); }
56
+ });
57
+ });
58
+ } catch (e) { return resolve(cached); }
59
+ req.on('timeout', function () { req.destroy(); resolve(cached); });
60
+ req.on('error', function () { resolve(cached); });
61
+ });
62
+ }
63
+ module.exports = { ensure, BUY_URL, storePath, pullFeed };
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@readystack/einvoice-lint-en16931",
3
+ "version": "0.1.0",
4
+ "description": "Catches retired profile identifiers, code-list values outside EN 16931 and malformed dates in your e-invoice XML, in the editor, before an access point rejects the file.",
5
+ "license": "SEE LICENSE IN LICENSE.txt",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "keywords": [
10
+ "xrechnung",
11
+ "factur-x",
12
+ "en16931",
13
+ "peppol",
14
+ "ubl"
15
+ ],
16
+ "homepage": "https://getreadystack.com",
17
+ "funding": "https://buy.polar.sh/polar_cl_uvaD8HrNLY041vZaDJqCklAVjAZRz5CnK5b184gKfFx",
18
+ "bin": {
19
+ "einvoice-lint-en16931": "cli.js"
20
+ },
21
+ "engines": {
22
+ "node": ">=18"
23
+ },
24
+ "files": [
25
+ "cli.js",
26
+ "license.js",
27
+ "rules.json",
28
+ "strings.json",
29
+ "README.md",
30
+ "LICENSE.txt"
31
+ ]
32
+ }
package/rules.json ADDED
@@ -0,0 +1,184 @@
1
+ [
2
+ {
3
+ "pattern": "xoev-de:kosit:standard:xrechnung_1\\.\\d",
4
+ "flags": "i",
5
+ "sev": "error",
6
+ "message": "XRechnung 1.x specification identifier (BT-24). Version 1.x has not been a valid XRechnung since 2020; a German public-sector or B2B receiver rejects the file at the gate.",
7
+ "fix": "urn:cen.eu:en16931:2017#compliant#urn:xoev-de:kosit:standard:xrechnung_3.0"
8
+ },
9
+ {
10
+ "pattern": "xoev-de:kosit:standard:xrechnung_2\\.\\d",
11
+ "flags": "i",
12
+ "sev": "error",
13
+ "message": "XRechnung 2.x specification identifier (BT-24). Version 2.3 stopped being valid on 1 February 2024, when 3.0 took over. This is the identifier a chatbot most often hands you.",
14
+ "fix": "urn:cen.eu:en16931:2017#compliant#urn:xoev-de:kosit:standard:xrechnung_3.0"
15
+ },
16
+ {
17
+ "pattern": "xoev-de:kosit:standard:xrechnung_3\\.0\\.\\d",
18
+ "flags": "i",
19
+ "sev": "error",
20
+ "message": "The bundle version (3.0.1 / 3.0.2) has been written into BT-24. The specification identifier stays at xrechnung_3.0 for the whole 3.0 line - only the KoSIT bundle carries the third digit.",
21
+ "fix": "urn:cen.eu:en16931:2017#compliant#urn:xoev-de:kosit:standard:xrechnung_3.0"
22
+ },
23
+ {
24
+ "pattern": "urn:factur-x\\.eu:1p0:minimum",
25
+ "flags": "i",
26
+ "sev": "error",
27
+ "message": "Factur-X MINIMUM profile. MINIMUM carries header data only - the line items stay in the PDF layer - so it is not EN 16931 compliant and the French B2B route does not accept it as a structured invoice.",
28
+ "fix": "urn:cen.eu:en16931:2017 (EN 16931 profile) or urn:cen.eu:en16931:2017#conformant#urn:factur-x.eu:1p0:extended"
29
+ },
30
+ {
31
+ "pattern": "urn:factur-x\\.eu:1p0:basicwl",
32
+ "flags": "i",
33
+ "sev": "error",
34
+ "message": "Factur-X BASIC WL profile. BASIC WL is 'without lines' - no invoice lines in the XML - so it fails EN 16931 the same way MINIMUM does.",
35
+ "fix": "urn:cen.eu:en16931:2017 (EN 16931 profile) or urn:cen.eu:en16931:2017#conformant#urn:factur-x.eu:1p0:extended"
36
+ },
37
+ {
38
+ "pattern": "urn:ferd:CrossIndustryDocument:invoice:1p0",
39
+ "flags": "i",
40
+ "sev": "error",
41
+ "message": "ZUGFeRD 1.0 context parameter. ZUGFeRD 1.0 predates EN 16931 and uses the old CrossIndustryDocument namespace; nothing in the 2026 mandates reads it.",
42
+ "fix": "Regenerate as ZUGFeRD 2.x / Factur-X: urn:cen.eu:en16931:2017"
43
+ },
44
+ {
45
+ "pattern": "urn:zugferd\\.de:2p0:",
46
+ "flags": "i",
47
+ "sev": "warn",
48
+ "message": "ZUGFeRD 2.0 profile identifier. From 2.1 onward the profiles moved to the shared Factur-X URNs; receivers that only know the current set will not match this one.",
49
+ "fix": "urn:cen.eu:en16931:2017 for the EN 16931 profile"
50
+ },
51
+ {
52
+ "pattern": "urn:www\\.cenbii\\.eu:transaction:biitrns",
53
+ "flags": "i",
54
+ "sev": "error",
55
+ "message": "CENBII / Peppol BIS 2.0 transaction identifier. That generation of the network was switched off in 2019; BIS Billing 3.0 replaced it.",
56
+ "fix": "urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0"
57
+ },
58
+ {
59
+ "pattern": "<cbc:ProfileID[^>]*>\\s*urn:fdc:peppol\\.eu:2017:poacc:billing:(?!01:1\\.0\\s*<)",
60
+ "flags": "",
61
+ "sev": "error",
62
+ "message": "Peppol BIS Billing profile identifier (BT-23) is not the billing process id. For an invoice or credit note it is always the 01:1.0 process, whatever the customization says.",
63
+ "fix": "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0"
64
+ },
65
+ {
66
+ "pattern": "<cbc:UBLVersionID>\\s*(?!2\\.1\\s*<)",
67
+ "flags": "",
68
+ "sev": "error",
69
+ "message": "UBLVersionID is not 2.1. EN 16931 and Peppol BIS Billing 3.0 are both bound to UBL 2.1; any other value makes the syntax binding undefined.",
70
+ "fix": "<cbc:UBLVersionID>2.1</cbc:UBLVersionID>"
71
+ },
72
+ {
73
+ "pattern": "<cbc:InvoiceTypeCode[^>]*>\\s*381\\s*<",
74
+ "flags": "",
75
+ "sev": "error",
76
+ "message": "Type code 381 (credit note) inside a UBL Invoice document. In UBL a credit note is its own root element with CreditNoteTypeCode; 381 in an Invoice is a document-type mismatch, not a code-list problem.",
77
+ "fix": "Send a <CreditNote> with <cbc:CreditNoteTypeCode>381</cbc:CreditNoteTypeCode>, or use 384 (corrected invoice) if you meant to replace an invoice."
78
+ },
79
+ {
80
+ "pattern": "<cbc:InvoiceTypeCode[^>]*>\\s*(?!(?:71|80|82|84|102|218|219|326|331|380|381|382|383|384|386|388|389|393|395|553|575|623|780|817|870|875|876|877)\\s*<)",
81
+ "flags": "",
82
+ "sev": "error",
83
+ "message": "Invoice type code (BT-3) is outside the UNTDID 1001 subset that EN 16931 and Peppol BIS Billing 3.0 allow. Codes invented outside the subset fail schematron before any business rule runs.",
84
+ "fix": "380 commercial invoice, 384 corrected invoice, 386 prepayment invoice, 389 self-billed invoice, 326 partial invoice, 875/876/877 construction invoices."
85
+ },
86
+ {
87
+ "pattern": "<ram:CategoryCode>\\s*(?!(?:AE|B|E|G|K|L|M|O|S|Z)\\s*<)",
88
+ "flags": "",
89
+ "sev": "error",
90
+ "message": "VAT category code is outside UNTDID 5305. The list has exactly ten entries and each one drives a different BR-* rule set, so a wrong letter silently changes which VAT rules are applied.",
91
+ "fix": "S standard, Z zero rated, E exempt, AE reverse charge, K intra-community, G export, O outside scope, L Canary Islands, M Ceuta and Melilla, B transferred VAT (Italy)."
92
+ },
93
+ {
94
+ "pattern": "<cbc:(?:IssueDate|DueDate|TaxPointDate|StartDate|EndDate|ActualDeliveryDate|PaymentDueDate)>\\s*(?!\\d{4}-\\d{2}-\\d{2}\\s*<)",
95
+ "flags": "",
96
+ "sev": "error",
97
+ "message": "Date is not in the ISO 8601 form the UBL binding requires. Local formats such as 31.12.2026 or 12/31/2026 are the single most common reason a first e-invoice bounces.",
98
+ "fix": "YYYY-MM-DD, for example 2026-09-08"
99
+ },
100
+ {
101
+ "pattern": "<ram:DateTimeString[^>]*format=\\\"(?!102\\\")",
102
+ "flags": "",
103
+ "sev": "error",
104
+ "message": "CII date format qualifier is not 102. EN 16931 binds every date in Cross Industry Invoice to UNTDID 2379 format 102, so 101, 203 or 204 are rejected even when the date itself is correct.",
105
+ "fix": "format=\"102\" with the value written as CCYYMMDD"
106
+ },
107
+ {
108
+ "pattern": "<ram:DateTimeString[^>]*format=\\\"102\\\"\\s*>\\s*(?!\\d{8}\\s*<)",
109
+ "flags": "",
110
+ "sev": "error",
111
+ "message": "CII date is declared as format 102 but the value is not eight digits. Format 102 is CCYYMMDD with no separators - a hyphenated date here contradicts its own qualifier.",
112
+ "fix": "20260908, not 2026-09-08"
113
+ },
114
+ {
115
+ "pattern": "<cbc:EndpointID>",
116
+ "flags": "",
117
+ "sev": "error",
118
+ "message": "Electronic address (BT-34 / BT-49) carries no schemeID. The identifier is meaningless without the EAS code that says what kind of address it is, and routing cannot resolve it.",
119
+ "fix": "<cbc:EndpointID schemeID=\"9930\">DE123456789</cbc:EndpointID> - 9930 German VAT, 9957 French VAT, 0009 SIRET, 0088 GLN, 0198 Danish CVR."
120
+ },
121
+ {
122
+ "pattern": "<cbc:EndpointID[^>]*schemeID=\\\"[A-Z]{2}:[A-Z]{2,}\\\"",
123
+ "flags": "",
124
+ "sev": "error",
125
+ "message": "Legacy Peppol party scheme (DE:VAT, FR:SIRET and similar) in schemeID. Those string schemes were replaced by four-digit EAS codes; an access point on the current network cannot look this up.",
126
+ "fix": "Use the numeric EAS code: 9930 for DE:VAT, 9957 for FR:VAT, 0009 for FR:SIRET, 9925 for BE:VAT."
127
+ },
128
+ {
129
+ "pattern": "<cbc:EndpointID[^>]*>[^<]*@",
130
+ "flags": "",
131
+ "sev": "warn",
132
+ "message": "The electronic address looks like an email address. Peppol routes on registered participant identifiers, not mailboxes, so an email here means the invoice has nowhere to go.",
133
+ "fix": "Use the receiver's registered participant id with its EAS schemeID."
134
+ },
135
+ {
136
+ "pattern": "<cbc:(?:TaxAmount|PayableAmount|LineExtensionAmount|TaxExclusiveAmount|TaxInclusiveAmount|TaxableAmount|AllowanceTotalAmount|ChargeTotalAmount|PrepaidAmount|PayableRoundingAmount|Percent|BaseQuantity|InvoicedQuantity|MultiplierFactorNumeric)[^>]*>\\s*-?\\d+,\\d",
137
+ "flags": "",
138
+ "sev": "error",
139
+ "message": "Decimal comma in a numeric value. XML numeric types use a dot regardless of locale, so 1.234,56 written this way is read as a different number or fails the type check outright.",
140
+ "fix": "Write 1234.56 - no thousands separator, dot as the decimal mark."
141
+ },
142
+ {
143
+ "pattern": "<cbc:(?:TaxAmount|PayableAmount|LineExtensionAmount|TaxExclusiveAmount|TaxInclusiveAmount|TaxableAmount|AllowanceTotalAmount|ChargeTotalAmount|PrepaidAmount|PayableRoundingAmount|PriceAmount)>",
144
+ "flags": "",
145
+ "sev": "error",
146
+ "message": "Amount element has no currencyID attribute. Every amount in EN 16931 is a qualified amount; without the currency the value is not typed and the totals cannot be checked.",
147
+ "fix": "<cbc:PayableAmount currencyID=\"EUR\">1234.56</cbc:PayableAmount>"
148
+ },
149
+ {
150
+ "pattern": "<cbc:(?:TaxAmount|PayableAmount|LineExtensionAmount|TaxExclusiveAmount|TaxInclusiveAmount|TaxableAmount|AllowanceTotalAmount|ChargeTotalAmount|PrepaidAmount|PayableRoundingAmount)[^>]*>\\s*-?\\d+\\.\\d{3,}",
151
+ "flags": "",
152
+ "sev": "error",
153
+ "message": "Amount carries more than two decimals. The BR-DEC rules cap document and line amounts at two decimals; only the unit price (BT-146) may go finer, so this is not a rounding preference.",
154
+ "fix": "Round the amount to two decimals and keep the extra precision in cbc:PriceAmount."
155
+ },
156
+ {
157
+ "pattern": "<cbc:Percent[^>]*>[^<]*%",
158
+ "flags": "",
159
+ "sev": "error",
160
+ "message": "Percent sign inside the VAT rate value. The element is a number, and the symbol makes it fail type validation before any VAT rule is evaluated.",
161
+ "fix": "<cbc:Percent>19</cbc:Percent>"
162
+ },
163
+ {
164
+ "pattern": "unitCode=\\\"[^\\\"]*[a-z]",
165
+ "flags": "",
166
+ "sev": "warn",
167
+ "message": "Unit of measure code is not upper case. UN/ECE Recommendation 20 codes are upper case throughout, so Stk, pcs or kg are not in the list even though the intent is obvious.",
168
+ "fix": "H87 piece, C62 one, KGM kilogram, LTR litre, MTR metre, MTK square metre, DAY day, HUR hour, TNE tonne."
169
+ },
170
+ {
171
+ "pattern": "<\\?xml[^>]*encoding=\\\"(?![Uu][Tt][Ff]-8\\\")",
172
+ "flags": "",
173
+ "sev": "error",
174
+ "message": "The XML declaration asks for an encoding other than UTF-8. Every EN 16931 syntax binding is UTF-8, and a Latin-1 file turns umlauts and accented names into invalid characters on the receiving side.",
175
+ "fix": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
176
+ },
177
+ {
178
+ "pattern": "<cbc:BuyerReference\\s*/>|<cbc:BuyerReference>\\s*</cbc:BuyerReference>",
179
+ "flags": "",
180
+ "sev": "error",
181
+ "message": "Buyer reference (BT-10) is present but empty. XRechnung makes BT-10 mandatory - it is where the Leitweg-ID goes - and an empty element counts as missing, not as optional.",
182
+ "fix": "<cbc:BuyerReference>04011000-12345-56</cbc:BuyerReference> - the Leitweg-ID the buyer gave you."
183
+ }
184
+ ]
package/strings.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "E-Invoice Lint for XRechnung, Factur-X and UBL",
3
+ "subtitle": "Catches retired profile identifiers, code-list values outside EN 16931 and malformed dates in your e-invoice XML, in the editor, before an access point rejects the file.",
4
+ "bin": "einvoice-lint-en16931",
5
+ "price": 29,
6
+ "free": "Check the open e-invoice against all 26 rules; Check only the lines you select; Reopen the last findings, with line numbers and severity; Read every rule that ships inside, in plain English",
7
+ "paid": "Check every e-invoice file in the repository; Findings report as CSV, JSON or HTML; JSON output a build step can fail on",
8
+ "need_key": "This option needs a licence (E-Invoice Lint for XRechnung, Factur-X and UBL).",
9
+ "exts": []
10
+ }