@readystack/einvoice-mandate-lint 1.0.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 +16 -0
- package/README.md +33 -0
- package/cli.js +71 -0
- package/engine.js +358 -0
- package/license.js +64 -0
- package/package.json +36 -0
- package/rules.json +445 -0
- package/strings.json +10 -0
package/LICENSE.txt
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
E-Invoice Mandate Lint - EU 2026 — Licence
|
|
2
|
+
|
|
3
|
+
Free scope
|
|
4
|
+
Lint the invoice XML you have open - every EN 16931 core field plus the country mandate rules its CustomizationID selects, reported with line numbers. is free for any use, personal or commercial, for as long as you keep the
|
|
5
|
+
extension installed, and the online check at the product page is free without registration.
|
|
6
|
+
|
|
7
|
+
Paid scope
|
|
8
|
+
Scan every invoice XML in the workspace in one pass and export the findings as JSON, CSV or SARIF you keep and run in CI. is the paid part. It asks for a licence key issued at purchase (one seat, one key;
|
|
9
|
+
7-day full refund, no questions). The key is checked against the payment provider and
|
|
10
|
+
cached locally for 30 days so it keeps working offline.
|
|
11
|
+
|
|
12
|
+
Redistribution
|
|
13
|
+
You may not resell, re-host or bundle this extension or its rule set. The rule set and the
|
|
14
|
+
engine are provided as-is; check results are advisory and do not constitute legal advice.
|
|
15
|
+
|
|
16
|
+
(c) ReadyStack — https://getreadystack.com
|
package/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# E-Invoice Mandate Lint - EU 2026
|
|
2
|
+
|
|
3
|
+
Lints UBL and CII invoice XML against EN 16931 and the national e-invoicing mandates that are live in 2026
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
npx @readystack/einvoice-mandate-lint file
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Node 18+. The same 35 rules as the VS Code extension, from a terminal or CI.
|
|
12
|
+
|
|
13
|
+
## Free
|
|
14
|
+
|
|
15
|
+
- Lint the invoice XML you have open - every EN 16931 core field plus the country mandate rules its CustomizationID and seller country select, reported with line numbers.
|
|
16
|
+
- `--rules` lists every rule
|
|
17
|
+
|
|
18
|
+
## With a licence ($29 once)
|
|
19
|
+
|
|
20
|
+
- Scan every invoice XML in the workspace in one pass and export the findings as JSON, CSV or SARIF you keep and run in CI.
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
@readystack/einvoice-mandate-lint --dir ./templates --report html --out report.html
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
An EN 16931 / Peppol integration consultant reviews an invoice mapping at about $170 an hour.
|
|
27
|
+
|
|
28
|
+
7-day refund, no questions. Set `READYSTACK_LICENSE=<key>` or run `--license <key>` once.
|
|
29
|
+
|
|
30
|
+
[Get a licence](https://buy.polar.sh/polar_cl_V1RBWcrS00NsYLx6EMXeji1xvbix6LF1yO9qR4DduBz)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
<!-- einvoice mandate lint -->
|
package/cli.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
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
|
+
const ENGINE = require('./engine.js'); // s144 — scaffold products: the same engine the VS Code extension runs
|
|
9
|
+
const RULE_LIST = Array.isArray(ENGINE.RULES) ? ENGINE.RULES : (Array.isArray(RULES) ? RULES : []);
|
|
10
|
+
const RULE_N = ENGINE.RULE_COUNT || RULE_LIST.length;
|
|
11
|
+
function scan(text, file) {
|
|
12
|
+
const r = ENGINE.engine.check(String(text), { today: new Date().toISOString().slice(0, 10), path: file || '' });
|
|
13
|
+
return (r && r.findings || []).map((f) => ({ line: parseInt(f.line, 10) || 1, msg: String(f.msg || f.message || f.check || ''), fix: f.fix || null, sev: (String(f.sev || 'error').toLowerCase().startsWith('err') ? 'error' : 'warn') }));
|
|
14
|
+
}
|
|
15
|
+
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; } }
|
|
16
|
+
function walk(dir, exts, out) {
|
|
17
|
+
let ents = []; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return out; }
|
|
18
|
+
for (const e of ents) {
|
|
19
|
+
if (e.name === 'node_modules' || e.name === '.git' || e.name.startsWith('.')) continue;
|
|
20
|
+
const p = path.join(dir, e.name);
|
|
21
|
+
if (e.isDirectory()) walk(p, exts, out);
|
|
22
|
+
else if ((!exts.length || exts.includes(path.extname(e.name).toLowerCase())) && isText(p)) out.push(p);
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
function esc(s) { return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
|
27
|
+
function csvq(s) { return '"' + String(s == null ? '' : s).replace(/"/g, '""') + '"'; }
|
|
28
|
+
function render(rows, fmt) {
|
|
29
|
+
if (fmt === 'json') return JSON.stringify({ tool: S.name, rules: RULE_N, files: rows }, null, 1);
|
|
30
|
+
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'; }
|
|
31
|
+
if (fmt === 'html') {
|
|
32
|
+
const n = rows.reduce((a, r) => a + r.hits.length, 0);
|
|
33
|
+
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>';
|
|
34
|
+
o += '<h1>' + esc(S.name) + '</h1><p>' + rows.length + ' files · ' + n + ' findings · ' + RULE_N + ' rules</p><table><tr><th>file</th><th>line</th><th>sev</th><th>message</th><th>fix</th></tr>';
|
|
35
|
+
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>';
|
|
36
|
+
return o + '</table>';
|
|
37
|
+
}
|
|
38
|
+
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'; }
|
|
39
|
+
return o;
|
|
40
|
+
}
|
|
41
|
+
function help() {
|
|
42
|
+
return [S.name + ' - ' + S.subtitle, '', 'Usage: ' + S.bin + ' <file> [more files] check the files you name (free, every rule)',
|
|
43
|
+
' ' + S.bin + ' --dir <folder> [--ext .html] scan a whole folder (licence)',
|
|
44
|
+
' ' + S.bin + ' ... --report csv|json|html [--out file] export a report (licence)',
|
|
45
|
+
' ' + S.bin + ' ... --ci exit 1 when an error-level finding exists (licence)',
|
|
46
|
+
' ' + S.bin + ' --license <key> store your licence key (or set READYSTACK_LICENSE)',
|
|
47
|
+
' ' + S.bin + ' --rules list the ' + RULE_N + ' rules', '',
|
|
48
|
+
'Free: ' + S.free, 'Licence ($' + S.price + ', once, 7-day refund): ' + S.paid, 'Get a licence: ' + lic.BUY_URL, ''].join('\n');
|
|
49
|
+
}
|
|
50
|
+
(async function main() {
|
|
51
|
+
try { const _feed = await lic.pullFeed(); if (_feed && Array.isArray(_feed.rules)) { if (Array.isArray(ENGINE.RULES)) for (const r of _feed.rules) ENGINE.RULES.push(r); } } catch (e) {} // ★s134 구독 피드 병합 (키 있는 손님만)
|
|
52
|
+
const a = process.argv.slice(2);
|
|
53
|
+
const get = (k) => { const i = a.indexOf(k); return i >= 0 ? a[i + 1] : null; };
|
|
54
|
+
if (!a.length || a.includes('--help') || a.includes('-h')) { process.stdout.write(help()); return; }
|
|
55
|
+
if (a.includes('--rules')) { process.stdout.write((RULE_LIST.length ? RULE_LIST.map((r, i) => String(i + 1).padStart(3) + ' [' + (r.sev || 'warn') + '] ' + (r.message || r.msg || r.id || '')) : Array.from({ length: RULE_N }, (_, i) => String(i + 1).padStart(3) + ' [engine] check ' + (i + 1))).join('\n') + '\n'); return; }
|
|
56
|
+
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); }
|
|
57
|
+
const dir = get('--dir'), fmt = get('--report'), out = get('--out'), ci = a.includes('--ci');
|
|
58
|
+
const exts = a.includes('--ext') ? [get('--ext')] : (S.exts || []);
|
|
59
|
+
const paid = !!(dir || fmt || ci);
|
|
60
|
+
if (paid) {
|
|
61
|
+
const r = await lic.ensure();
|
|
62
|
+
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); }
|
|
63
|
+
}
|
|
64
|
+
const files = dir ? walk(dir, exts, []) : a.filter((x, i) => !x.startsWith('--') && !['--dir', '--report', '--out', '--ext', '--license'].includes(a[i - 1]));
|
|
65
|
+
if (!files.length) { process.stderr.write('No files. ' + S.bin + ' --help\n'); process.exit(2); }
|
|
66
|
+
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) }; });
|
|
67
|
+
const text = render(rows, fmt || 'text');
|
|
68
|
+
if (out) fs.writeFileSync(out, text); else process.stdout.write(text.endsWith('\n') ? text : text + '\n');
|
|
69
|
+
const errors = rows.reduce((n, r) => n + r.hits.filter((h) => h.sev === 'error').length, 0);
|
|
70
|
+
if (ci && errors) process.exit(1);
|
|
71
|
+
})().catch((e) => { process.stderr.write(String(e && e.stack || e) + '\n'); process.exit(3); });
|
package/engine.js
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/* E-Invoice Mandate Lint - engine
|
|
2
|
+
* One brain, two homes: node (VS Code extension) and the browser (free web page).
|
|
3
|
+
*/
|
|
4
|
+
(function () {
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
var RULES = (typeof module !== 'undefined' && module.exports)
|
|
8
|
+
? require('./rules.json')
|
|
9
|
+
: window.EINV_RULES;
|
|
10
|
+
|
|
11
|
+
var byId = {};
|
|
12
|
+
RULES.rules.forEach(function (r) { byId[r.id] = r; });
|
|
13
|
+
|
|
14
|
+
// ---------- tiny XML reader (namespace-prefix tolerant) ----------
|
|
15
|
+
|
|
16
|
+
function localName(tag) {
|
|
17
|
+
var i = tag.indexOf(':');
|
|
18
|
+
return i < 0 ? tag : tag.slice(i + 1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function lineAt(text, index) {
|
|
22
|
+
if (index < 0) return 1;
|
|
23
|
+
return text.slice(0, index).split('\n').length;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// First <...:Name ...> ... </...:Name> block inside `text`. Returns null if absent.
|
|
27
|
+
function block(text, name, from) {
|
|
28
|
+
var ln = localName(name);
|
|
29
|
+
var open = new RegExp('<(?:[A-Za-z0-9_.-]+:)?' + ln + '(\\s[^>]*)?(/)?>', 'g');
|
|
30
|
+
open.lastIndex = from || 0;
|
|
31
|
+
var m = open.exec(text);
|
|
32
|
+
if (!m) return null;
|
|
33
|
+
if (m[2] === '/') return { inner: '', start: m.index, end: open.lastIndex, self: true };
|
|
34
|
+
var close = new RegExp('</(?:[A-Za-z0-9_.-]+:)?' + ln + '>', 'g');
|
|
35
|
+
close.lastIndex = open.lastIndex;
|
|
36
|
+
var c = close.exec(text);
|
|
37
|
+
if (!c) return null;
|
|
38
|
+
return {
|
|
39
|
+
inner: text.slice(open.lastIndex, c.index),
|
|
40
|
+
attrs: m[1] || '',
|
|
41
|
+
start: m.index,
|
|
42
|
+
innerStart: open.lastIndex,
|
|
43
|
+
end: close.lastIndex
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Walk a chain of element names. Each hop searches inside the previous hop.
|
|
48
|
+
// Returns {value, line, attrs} or null.
|
|
49
|
+
function pick(text, offset, chain) {
|
|
50
|
+
var cur = text, base = offset || 0;
|
|
51
|
+
for (var i = 0; i < chain.length; i++) {
|
|
52
|
+
var b = block(cur, chain[i]);
|
|
53
|
+
if (!b) return null;
|
|
54
|
+
if (i === chain.length - 1) {
|
|
55
|
+
return {
|
|
56
|
+
value: b.inner.replace(/<[^>]*>/g, '').trim(),
|
|
57
|
+
attrs: b.attrs || '',
|
|
58
|
+
line: lineAt(text, base + b.start) + (base ? 0 : 0)
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
base = base + b.innerStart;
|
|
62
|
+
cur = b.inner;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function allBlocks(text, name) {
|
|
68
|
+
var out = [], from = 0, b;
|
|
69
|
+
while ((b = block(text, name, from))) {
|
|
70
|
+
out.push(b);
|
|
71
|
+
from = b.end;
|
|
72
|
+
if (out.length > 5000) break;
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function num(s) {
|
|
78
|
+
if (s === null || s === undefined || s === '') return null;
|
|
79
|
+
var v = parseFloat(String(s).replace(/[^0-9.\-]/g, ''));
|
|
80
|
+
return isNaN(v) ? null : v;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function round2(v) { return Math.round(v * 100) / 100; }
|
|
84
|
+
|
|
85
|
+
// ---------- document facts ----------
|
|
86
|
+
|
|
87
|
+
function readDoc(text) {
|
|
88
|
+
var d = { text: text, syntax: null, root: null, line: {} };
|
|
89
|
+
|
|
90
|
+
var rootMatch = /<(?:[A-Za-z0-9_.-]+:)?(Invoice|CreditNote|CrossIndustryInvoice|Faktura)(\s|>)/.exec(text);
|
|
91
|
+
if (rootMatch) {
|
|
92
|
+
d.root = rootMatch[1];
|
|
93
|
+
d.syntax = RULES.syntaxes[d.root] || null;
|
|
94
|
+
d.rootLine = lineAt(text, rootMatch.index);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
var cii = d.root === 'CrossIndustryInvoice';
|
|
98
|
+
|
|
99
|
+
function g(chain) { return pick(text, 0, chain); }
|
|
100
|
+
|
|
101
|
+
d.customization = cii
|
|
102
|
+
? g(['GuidelineSpecifiedDocumentContextParameter', 'ID'])
|
|
103
|
+
: g(['CustomizationID']);
|
|
104
|
+
|
|
105
|
+
d.sellerCountry = g(['AccountingSupplierParty', 'PostalAddress', 'IdentificationCode'])
|
|
106
|
+
|| g(['SellerTradeParty', 'PostalTradeAddress', 'CountryID']);
|
|
107
|
+
d.buyerCountry = g(['AccountingCustomerParty', 'PostalAddress', 'IdentificationCode'])
|
|
108
|
+
|| g(['BuyerTradeParty', 'PostalTradeAddress', 'CountryID']);
|
|
109
|
+
|
|
110
|
+
d.sellerLegalId = g(['AccountingSupplierParty', 'PartyLegalEntity', 'CompanyID']);
|
|
111
|
+
d.buyerLegalId = g(['AccountingCustomerParty', 'PartyLegalEntity', 'CompanyID']);
|
|
112
|
+
d.buyerReference = g(['BuyerReference']);
|
|
113
|
+
|
|
114
|
+
var sup = block(text, 'AccountingSupplierParty');
|
|
115
|
+
d.sellerContact = sup ? {
|
|
116
|
+
name: pick(sup.inner, 0, ['Contact', 'Name']),
|
|
117
|
+
tel: pick(sup.inner, 0, ['Contact', 'Telephone']),
|
|
118
|
+
mail: pick(sup.inner, 0, ['Contact', 'ElectronicMail'])
|
|
119
|
+
} : { name: null, tel: null, mail: null };
|
|
120
|
+
d.sellerEndpoint = sup ? pick(sup.inner, 0, ['EndpointID']) : null;
|
|
121
|
+
var cus = block(text, 'AccountingCustomerParty');
|
|
122
|
+
d.buyerEndpoint = cus ? pick(cus.inner, 0, ['EndpointID']) : null;
|
|
123
|
+
|
|
124
|
+
d.paymentMeans = block(text, 'PaymentMeans');
|
|
125
|
+
d.dueDate = g(['DueDate']);
|
|
126
|
+
d.paymentTerms = block(text, 'PaymentTerms');
|
|
127
|
+
|
|
128
|
+
d.lines = allBlocks(text, cii ? 'IncludedSupplyChainTradeLineItem' : 'InvoiceLine');
|
|
129
|
+
d.lineSum = 0;
|
|
130
|
+
d.lines.forEach(function (l) {
|
|
131
|
+
var a = pick(l.inner, 0, ['LineExtensionAmount']) || pick(l.inner, 0, ['LineTotalAmount']);
|
|
132
|
+
var v = a ? num(a.value) : null;
|
|
133
|
+
if (v !== null) d.lineSum += v;
|
|
134
|
+
});
|
|
135
|
+
d.lineSum = round2(d.lineSum);
|
|
136
|
+
|
|
137
|
+
var tot = block(text, 'LegalMonetaryTotal') || block(text, 'SpecifiedTradeSettlementHeaderMonetarySummation');
|
|
138
|
+
d.totals = {};
|
|
139
|
+
['LineExtensionAmount', 'TaxExclusiveAmount', 'TaxInclusiveAmount', 'PayableAmount',
|
|
140
|
+
'PrepaidAmount', 'PayableRoundingAmount'].forEach(function (k) {
|
|
141
|
+
var p = tot ? pick(tot.inner, 0, [k]) : null;
|
|
142
|
+
d.totals[k] = p ? { value: num(p.value), line: tot ? lineAt(text, tot.innerStart + p.line) : 1 } : null;
|
|
143
|
+
});
|
|
144
|
+
if (tot) {
|
|
145
|
+
d.totalsLine = lineAt(text, tot.start);
|
|
146
|
+
// re-resolve each amount's line against the whole document for accurate anchors
|
|
147
|
+
['LineExtensionAmount', 'TaxExclusiveAmount', 'TaxInclusiveAmount', 'PayableAmount',
|
|
148
|
+
'PrepaidAmount', 'PayableRoundingAmount'].forEach(function (k) {
|
|
149
|
+
if (!d.totals[k]) return;
|
|
150
|
+
var b = block(tot.inner, k);
|
|
151
|
+
d.totals[k].line = lineAt(text, tot.innerStart + b.start);
|
|
152
|
+
});
|
|
153
|
+
} else {
|
|
154
|
+
d.totalsLine = 1;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
var tax = block(text, 'TaxTotal');
|
|
158
|
+
var ta = tax ? pick(tax.inner, 0, ['TaxAmount']) : null;
|
|
159
|
+
d.taxAmount = ta ? { value: num(ta.value), line: lineAt(text, tax.innerStart + block(tax.inner, 'TaxAmount').start) } : null;
|
|
160
|
+
|
|
161
|
+
d.profile = null;
|
|
162
|
+
if (d.customization && d.customization.value) {
|
|
163
|
+
d.profile = RULES.profiles[d.customization.value.trim()] || null;
|
|
164
|
+
}
|
|
165
|
+
d.country = (d.sellerCountry && d.sellerCountry.value)
|
|
166
|
+
? d.sellerCountry.value.trim().toUpperCase()
|
|
167
|
+
: (d.profile && d.profile.country && d.profile.country !== '*' ? d.profile.country : null);
|
|
168
|
+
d.domestic = !!(d.country && d.buyerCountry &&
|
|
169
|
+
d.buyerCountry.value.trim().toUpperCase() === d.country);
|
|
170
|
+
d.mandate = null;
|
|
171
|
+
for (var i = 0; i < RULES.mandates.length; i++) {
|
|
172
|
+
if (RULES.mandates[i].country === d.country) { d.mandate = RULES.mandates[i]; break; }
|
|
173
|
+
}
|
|
174
|
+
return d;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------- checks ----------
|
|
178
|
+
|
|
179
|
+
function check(text, opts) {
|
|
180
|
+
opts = opts || {};
|
|
181
|
+
var today = opts.today || '2026-09-11';
|
|
182
|
+
text = String(text || '');
|
|
183
|
+
var findings = [];
|
|
184
|
+
var d = readDoc(text);
|
|
185
|
+
|
|
186
|
+
function add(id, extra, line) {
|
|
187
|
+
var r = byId[id];
|
|
188
|
+
if (!r) return;
|
|
189
|
+
findings.push({
|
|
190
|
+
check: r.id,
|
|
191
|
+
sev: r.sev,
|
|
192
|
+
msg: r.msg + (extra ? ' - ' + extra : ''),
|
|
193
|
+
line: line || 1,
|
|
194
|
+
bt: r.bt,
|
|
195
|
+
fix: r.fix
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (!d.syntax) {
|
|
200
|
+
add('EINV-SYNTAX', d.root ? 'found <' + d.root + '>' : 'no invoice root element found', d.rootLine || 1);
|
|
201
|
+
if (d.root === 'Faktura') {
|
|
202
|
+
// Polish KSeF document: check the schema variant and stop.
|
|
203
|
+
var fa = /wersja\s*=\s*"?(1|2|3)/.exec(text) || /FA\s*\(?\s*([23])\s*\)?/.exec(text);
|
|
204
|
+
var isFa2 = /http:\/\/crd\.gov\.pl\/wzor\/2023\/06\/29\/12648/.test(text) || /"FA\(2\)"/.test(text);
|
|
205
|
+
if (isFa2) add('EINV-PL-FA2', 'FA(3) is required since ' + mandateDate('PL'), 1);
|
|
206
|
+
}
|
|
207
|
+
return finish(findings, d, today);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// profile
|
|
211
|
+
if (!d.customization || !d.customization.value) {
|
|
212
|
+
add('EINV-BT-24', null, d.rootLine || 1);
|
|
213
|
+
} else if (!d.profile) {
|
|
214
|
+
add('EINV-PROFILE-UNKNOWN', '"' + d.customization.value + '"', d.customization.line);
|
|
215
|
+
} else if (d.profile.status === 'retired') {
|
|
216
|
+
add('EINV-PROFILE-RETIRED',
|
|
217
|
+
d.profile.label + ' was retired on ' + d.profile.retired_on + '; use ' + d.profile.replaced_by,
|
|
218
|
+
d.customization.line);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// presence rules
|
|
222
|
+
RULES.rules.forEach(function (r) {
|
|
223
|
+
if (r.kind !== 'present') return;
|
|
224
|
+
var p = pick(text, 0, r.path);
|
|
225
|
+
if (!p || !p.value) add(r.id, null, d.rootLine || 1);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
if (d.lines.length === 0) add('EINV-LINE', null, d.rootLine || 1);
|
|
229
|
+
|
|
230
|
+
// arithmetic
|
|
231
|
+
var T = d.totals;
|
|
232
|
+
if (T.LineExtensionAmount && d.lines.length &&
|
|
233
|
+
round2(T.LineExtensionAmount.value) !== d.lineSum) {
|
|
234
|
+
add('EINV-BR-CO-10',
|
|
235
|
+
'declared ' + T.LineExtensionAmount.value.toFixed(2) + ', lines add up to ' + d.lineSum.toFixed(2),
|
|
236
|
+
T.LineExtensionAmount.line);
|
|
237
|
+
}
|
|
238
|
+
if (T.TaxExclusiveAmount && T.TaxInclusiveAmount && d.taxAmount) {
|
|
239
|
+
var expect = round2(T.TaxExclusiveAmount.value + d.taxAmount.value);
|
|
240
|
+
if (round2(T.TaxInclusiveAmount.value) !== expect) {
|
|
241
|
+
add('EINV-BR-CO-15',
|
|
242
|
+
'declared ' + T.TaxInclusiveAmount.value.toFixed(2) + ', ' +
|
|
243
|
+
T.TaxExclusiveAmount.value.toFixed(2) + ' + ' + d.taxAmount.value.toFixed(2) +
|
|
244
|
+
' = ' + expect.toFixed(2),
|
|
245
|
+
T.TaxInclusiveAmount.line);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (T.PayableAmount && T.TaxInclusiveAmount) {
|
|
249
|
+
var pre = T.PrepaidAmount ? T.PrepaidAmount.value : 0;
|
|
250
|
+
var rnd = T.PayableRoundingAmount ? T.PayableRoundingAmount.value : 0;
|
|
251
|
+
var due = round2(T.TaxInclusiveAmount.value - pre + rnd);
|
|
252
|
+
if (round2(T.PayableAmount.value) !== due) {
|
|
253
|
+
add('EINV-BR-CO-16',
|
|
254
|
+
'declared ' + T.PayableAmount.value.toFixed(2) + ', expected ' + due.toFixed(2),
|
|
255
|
+
T.PayableAmount.line);
|
|
256
|
+
}
|
|
257
|
+
if (due > 0 && !d.dueDate && !d.paymentTerms) {
|
|
258
|
+
add('EINV-BR-CO-25', due.toFixed(2) + ' is due', T.PayableAmount.line);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// country mandate rules
|
|
263
|
+
var c = d.country;
|
|
264
|
+
var live = mandateLive(c, today);
|
|
265
|
+
|
|
266
|
+
if (c === 'FR' && live) {
|
|
267
|
+
if (!d.sellerLegalId || !/^\d{9}(\d{5})?$/.test(d.sellerLegalId.value.replace(/\s/g, ''))) {
|
|
268
|
+
add('EINV-FR-SIREN',
|
|
269
|
+
d.sellerLegalId ? 'found "' + d.sellerLegalId.value + '"' : 'no cac:PartyLegalEntity/cbc:CompanyID',
|
|
270
|
+
d.sellerLegalId ? d.sellerLegalId.line : d.rootLine);
|
|
271
|
+
}
|
|
272
|
+
if (d.domestic && (!d.buyerLegalId || !/^\d{9}(\d{5})?$/.test(d.buyerLegalId.value.replace(/\s/g, '')))) {
|
|
273
|
+
add('EINV-FR-BUYER-SIREN',
|
|
274
|
+
d.buyerLegalId ? 'found "' + d.buyerLegalId.value + '"' : 'no buyer SIREN/SIRET',
|
|
275
|
+
d.buyerLegalId ? d.buyerLegalId.line : d.rootLine);
|
|
276
|
+
}
|
|
277
|
+
if (d.profile && d.profile.status === 'insufficient') {
|
|
278
|
+
add('EINV-FR-MINIMUM', d.profile.note, d.customization.line);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
var isXR = !!(d.customization && /xrechnung/i.test(d.customization.value));
|
|
283
|
+
if (c === 'DE' || isXR) {
|
|
284
|
+
if (!d.buyerReference || !d.buyerReference.value) {
|
|
285
|
+
add('EINV-DE-LEITWEG', 'BR-DE-15', d.rootLine);
|
|
286
|
+
}
|
|
287
|
+
var miss = [];
|
|
288
|
+
if (!d.sellerContact.name) miss.push('cbc:Name');
|
|
289
|
+
if (!d.sellerContact.tel) miss.push('cbc:Telephone');
|
|
290
|
+
if (!d.sellerContact.mail) miss.push('cbc:ElectronicMail');
|
|
291
|
+
if (miss.length) add('EINV-DE-CONTACT', 'missing ' + miss.join(', '), d.rootLine);
|
|
292
|
+
if (!d.paymentMeans) add('EINV-DE-PAYMENT', 'BR-DE-1', d.rootLine);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (c === 'BE' && live) {
|
|
296
|
+
if (!d.sellerEndpoint || !d.buyerEndpoint ||
|
|
297
|
+
!/schemeID/i.test((d.sellerEndpoint.attrs || '') + (d.buyerEndpoint.attrs || ''))) {
|
|
298
|
+
add('EINV-BE-PEPPOL',
|
|
299
|
+
(d.sellerEndpoint ? '' : 'seller ') + (d.buyerEndpoint ? '' : 'buyer ') + 'endpoint missing',
|
|
300
|
+
d.rootLine);
|
|
301
|
+
}
|
|
302
|
+
if (d.customization && !/peppol/i.test(d.customization.value)) {
|
|
303
|
+
add('EINV-BE-PROFILE', 'declares "' + d.customization.value + '"', d.customization.line);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (c === 'PL' && d.domestic && live) {
|
|
308
|
+
add('EINV-PL-KSEF', 'FA(3) mandatory since ' + mandateDate('PL'), d.rootLine);
|
|
309
|
+
}
|
|
310
|
+
if (c === 'IT' && d.domestic) {
|
|
311
|
+
add('EINV-IT-SDI', null, d.rootLine);
|
|
312
|
+
}
|
|
313
|
+
if (c === 'ES') {
|
|
314
|
+
add('EINV-ES-VERIFACTU', null, d.rootLine);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return finish(findings, d, today);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function mandateDate(country) {
|
|
321
|
+
for (var i = 0; i < RULES.mandates.length; i++) {
|
|
322
|
+
if (RULES.mandates[i].country === country) return RULES.mandates[i].issue_from;
|
|
323
|
+
}
|
|
324
|
+
return '';
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function mandateLive(country, today) {
|
|
328
|
+
if (!country) return false;
|
|
329
|
+
for (var i = 0; i < RULES.mandates.length; i++) {
|
|
330
|
+
if (RULES.mandates[i].country === country) return RULES.mandates[i].receive_from <= today;
|
|
331
|
+
}
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function finish(findings, d, today) {
|
|
336
|
+
if (d.mandate) {
|
|
337
|
+
var r = byId['EINV-MANDATE-LIVE'];
|
|
338
|
+
var live = d.mandate.receive_from <= today;
|
|
339
|
+
findings.push({
|
|
340
|
+
check: r.id,
|
|
341
|
+
sev: 'info',
|
|
342
|
+
msg: d.mandate.name + ': receive from ' + d.mandate.receive_from +
|
|
343
|
+
', issue from ' + d.mandate.issue_from +
|
|
344
|
+
' (' + (live ? 'live as of ' + today : 'not yet in force on ' + today) + ')',
|
|
345
|
+
line: 1,
|
|
346
|
+
bt: '-',
|
|
347
|
+
fix: d.mandate.note
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
findings.sort(function (a, b) { return (a.line - b.line) || a.check.localeCompare(b.check); });
|
|
351
|
+
return { findings: findings };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
var api = { engine: { check: check }, RULES: RULES, RULE_COUNT: RULES.rules.length };
|
|
355
|
+
|
|
356
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
357
|
+
if (typeof window !== 'undefined') window.EINVENGINE = api;
|
|
358
|
+
})();
|
package/license.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
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 BENEFIT_ID = '10e86de0-e5ca-4464-b6cf-d7bc9f5bc65f'; // s142 — this product's Polar benefit (s140 law: ask with benefit_id, or one key opens every product)
|
|
6
|
+
const BUY_URL = 'https://buy.polar.sh/polar_cl_V1RBWcrS00NsYLx6EMXeji1xvbix6LF1yO9qR4DduBz';
|
|
7
|
+
const SLUG = 'einvoice-mandate-lint';
|
|
8
|
+
const GRACE_MS = 30 * 24 * 3600 * 1000; // after a successful check, 30 days work offline
|
|
9
|
+
const RECHECK_MS = 7 * 24 * 3600 * 1000; // re-ask Polar every 7 days (refunds / cancellations)
|
|
10
|
+
function storePath() { return path.join(process.env.READYSTACK_HOME || path.join(os.homedir(), '.config', 'readystack'), SLUG + '.json'); }
|
|
11
|
+
function load() { try { return JSON.parse(fs.readFileSync(storePath(), 'utf8')); } catch (e) { return {}; } }
|
|
12
|
+
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 */ } }
|
|
13
|
+
function validate(key) {
|
|
14
|
+
return new Promise(function (resolve) {
|
|
15
|
+
if (!ORG_ID) return resolve({ ok: false, offline: false });
|
|
16
|
+
const body = JSON.stringify(/^[0-9a-f-]{36}$/.test(BENEFIT_ID) ? { key: key, organization_id: ORG_ID, benefit_id: BENEFIT_ID } : { key: key, organization_id: ORG_ID });
|
|
17
|
+
const req = https.request({ hostname: 'api.polar.sh', path: '/v1/customer-portal/license-keys/validate', method: 'POST', timeout: 8000,
|
|
18
|
+
headers: { 'content-type': 'application/json', 'polar-version': '2026-04', 'content-length': Buffer.byteLength(body) } }, function (res) {
|
|
19
|
+
let buf = ''; res.on('data', function (d) { buf += d; });
|
|
20
|
+
res.on('end', function () {
|
|
21
|
+
if (res.statusCode !== 200) return resolve({ ok: false, offline: false });
|
|
22
|
+
try { const j = JSON.parse(buf); resolve({ ok: j && (j.status === 'granted' || j.valid === true || !!j.id), offline: false }); }
|
|
23
|
+
catch (e) { resolve({ ok: false, offline: false }); }
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
req.on('timeout', function () { req.destroy(); resolve({ ok: false, offline: true }); });
|
|
27
|
+
req.on('error', function () { resolve({ ok: false, offline: true }); });
|
|
28
|
+
req.write(body); req.end();
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
async function ensure(explicitKey) {
|
|
32
|
+
const st = load();
|
|
33
|
+
const key = explicitKey || process.env.READYSTACK_LICENSE || st.key;
|
|
34
|
+
if (!key) return { ok: false, why: 'no_key' };
|
|
35
|
+
const age = Date.now() - (st.okAt || 0);
|
|
36
|
+
if (!explicitKey && st.key === key && age < RECHECK_MS) return { ok: true, cached: true };
|
|
37
|
+
const r = await validate(String(key).trim());
|
|
38
|
+
if (r.ok) { save({ key: String(key).trim(), okAt: Date.now() }); return { ok: true }; }
|
|
39
|
+
if (r.offline && st.key === key && age < GRACE_MS) return { ok: true, offline: true };
|
|
40
|
+
return { ok: false, why: r.offline ? 'offline' : 'invalid' };
|
|
41
|
+
}
|
|
42
|
+
// ★s134 — 구독 규칙 피드(층3 "바뀌면 업데이트"): 키 있는 손님만 · 7일마다 · 오프라인은 캐시. 워커 GET /api/rules/<slug>?key=
|
|
43
|
+
const FEED_URL = 'https://getreadystack.com/api/rules/';
|
|
44
|
+
function pullFeed() {
|
|
45
|
+
const st = load(); const key = process.env.READYSTACK_LICENSE || st.key; const cached = st.feed || null;
|
|
46
|
+
if (!key) return Promise.resolve(cached);
|
|
47
|
+
if (cached && (Date.now() - (st.feedAt || 0)) < RECHECK_MS) return Promise.resolve(cached);
|
|
48
|
+
return new Promise(function (resolve) {
|
|
49
|
+
let req;
|
|
50
|
+
try {
|
|
51
|
+
req = https.get(FEED_URL + encodeURIComponent(SLUG) + '?key=' + encodeURIComponent(key), { timeout: 8000, headers: { 'user-agent': 'readystack-cli' } }, function (res) {
|
|
52
|
+
let buf = ''; res.on('data', function (d) { buf += d; });
|
|
53
|
+
res.on('end', function () {
|
|
54
|
+
if (res.statusCode !== 200) return resolve(cached);
|
|
55
|
+
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); }
|
|
56
|
+
catch (e) { resolve(cached); }
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
} catch (e) { return resolve(cached); }
|
|
60
|
+
req.on('timeout', function () { req.destroy(); resolve(cached); });
|
|
61
|
+
req.on('error', function () { resolve(cached); });
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
module.exports = { ensure, BUY_URL, storePath, pullFeed };
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@readystack/einvoice-mandate-lint",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Lints UBL and CII invoice XML against EN 16931 and the national e-invoicing mandates that are live in 2026",
|
|
5
|
+
"license": "SEE LICENSE IN LICENSE.txt",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"e-invoicing",
|
|
11
|
+
"en16931",
|
|
12
|
+
"peppol",
|
|
13
|
+
"xrechnung",
|
|
14
|
+
"ubl",
|
|
15
|
+
"factur-x",
|
|
16
|
+
"ksef",
|
|
17
|
+
"vat"
|
|
18
|
+
],
|
|
19
|
+
"homepage": "https://getreadystack.com",
|
|
20
|
+
"funding": "https://buy.polar.sh/polar_cl_V1RBWcrS00NsYLx6EMXeji1xvbix6LF1yO9qR4DduBz",
|
|
21
|
+
"bin": {
|
|
22
|
+
"einvoice-mandate-lint": "cli.js"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"cli.js",
|
|
29
|
+
"license.js",
|
|
30
|
+
"rules.json",
|
|
31
|
+
"strings.json",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE.txt",
|
|
34
|
+
"engine.js"
|
|
35
|
+
]
|
|
36
|
+
}
|
package/rules.json
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "2026.09.11",
|
|
3
|
+
"syntaxes": {
|
|
4
|
+
"Invoice": "UBL 2.1 Invoice",
|
|
5
|
+
"CreditNote": "UBL 2.1 CreditNote",
|
|
6
|
+
"CrossIndustryInvoice": "UN/CEFACT CII D16B"
|
|
7
|
+
},
|
|
8
|
+
"profiles": {
|
|
9
|
+
"urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0": {
|
|
10
|
+
"label": "Peppol BIS Billing 3.0",
|
|
11
|
+
"status": "current",
|
|
12
|
+
"country": "*"
|
|
13
|
+
},
|
|
14
|
+
"urn:cen.eu:en16931:2017": {
|
|
15
|
+
"label": "EN 16931 core, no CIUS",
|
|
16
|
+
"status": "current",
|
|
17
|
+
"country": "*"
|
|
18
|
+
},
|
|
19
|
+
"urn:cen.eu:en16931:2017#compliant#urn:xoev-de:kosit:standard:xrechnung_3.0": {
|
|
20
|
+
"label": "XRechnung 3.0 (KoSIT)",
|
|
21
|
+
"status": "current",
|
|
22
|
+
"country": "DE"
|
|
23
|
+
},
|
|
24
|
+
"urn:cen.eu:en16931:2017#compliant#urn:xoev-de:kosit:standard:xrechnung_2.3": {
|
|
25
|
+
"label": "XRechnung 2.3",
|
|
26
|
+
"status": "retired",
|
|
27
|
+
"retired_on": "2025-02-06",
|
|
28
|
+
"replaced_by": "urn:cen.eu:en16931:2017#compliant#urn:xoev-de:kosit:standard:xrechnung_3.0",
|
|
29
|
+
"country": "DE"
|
|
30
|
+
},
|
|
31
|
+
"urn:cen.eu:en16931:2017#compliant#urn:xoev-de:kosit:standard:xrechnung_2.2": {
|
|
32
|
+
"label": "XRechnung 2.2",
|
|
33
|
+
"status": "retired",
|
|
34
|
+
"retired_on": "2024-02-01",
|
|
35
|
+
"replaced_by": "urn:cen.eu:en16931:2017#compliant#urn:xoev-de:kosit:standard:xrechnung_3.0",
|
|
36
|
+
"country": "DE"
|
|
37
|
+
},
|
|
38
|
+
"urn:cen.eu:en16931:2017#compliant#urn:factur-x.eu:1p0:basic": {
|
|
39
|
+
"label": "Factur-X 1.0 BASIC",
|
|
40
|
+
"status": "current",
|
|
41
|
+
"country": "FR"
|
|
42
|
+
},
|
|
43
|
+
"urn:cen.eu:en16931:2017#compliant#urn:factur-x.eu:1p0:en16931": {
|
|
44
|
+
"label": "Factur-X 1.0 EN 16931 (COMFORT)",
|
|
45
|
+
"status": "current",
|
|
46
|
+
"country": "FR"
|
|
47
|
+
},
|
|
48
|
+
"urn:factur-x.eu:1p0:minimum": {
|
|
49
|
+
"label": "Factur-X MINIMUM",
|
|
50
|
+
"status": "insufficient",
|
|
51
|
+
"country": "FR",
|
|
52
|
+
"note": "MINIMUM is not an EN 16931 compliant profile and is not a valid structured invoice for the French B2B mandate."
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"mandates": [
|
|
56
|
+
{
|
|
57
|
+
"country": "FR",
|
|
58
|
+
"name": "France - Plateforme Agreee / Factur-X",
|
|
59
|
+
"receive_from": "2026-09-01",
|
|
60
|
+
"issue_from": "2026-09-01",
|
|
61
|
+
"note": "Every VAT-registered business in France must be able to receive structured e-invoices from 2026-09-01; large and mid-sized companies must also issue from that date, SMEs and micro-enterprises from 2027-09-01. Accepted core formats: Factur-X, UBL 2.1, CII."
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"country": "DE",
|
|
65
|
+
"name": "Germany - E-Rechnung / XRechnung",
|
|
66
|
+
"receive_from": "2025-01-01",
|
|
67
|
+
"issue_from": "2027-01-01",
|
|
68
|
+
"note": "Receipt of structured e-invoices is mandatory since 2025-01-01. Issuing becomes mandatory 2027-01-01 for sellers above EUR 800,000 prior-year turnover and 2028-01-01 for everyone else."
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"country": "BE",
|
|
72
|
+
"name": "Belgium - Peppol B2B",
|
|
73
|
+
"receive_from": "2026-01-01",
|
|
74
|
+
"issue_from": "2026-01-01",
|
|
75
|
+
"note": "Domestic B2B structured e-invoicing is mandatory since 2026-01-01; the tolerance period ended 2026-03-31 and penalties are enforced. Peppol BIS Billing 3.0 is the reference format."
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"country": "PL",
|
|
79
|
+
"name": "Poland - KSeF FA(3)",
|
|
80
|
+
"receive_from": "2026-02-01",
|
|
81
|
+
"issue_from": "2026-02-01",
|
|
82
|
+
"note": "FA(2) was replaced by the FA(3) XML structure on 2026-02-01. KSeF issuing is mandatory from 2026-02-01 above PLN 200m prior-year turnover, from 2026-04-01 for all other VAT payers, and from 2027-01-01 for micro-enterprises."
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"country": "ES",
|
|
86
|
+
"name": "Spain - Verifactu / SIF",
|
|
87
|
+
"receive_from": "2027-01-01",
|
|
88
|
+
"issue_from": "2027-01-01",
|
|
89
|
+
"note": "Verifactu was postponed by one year: Corporate Income Tax payers from 2027-01-01 and other taxpayers, including autonomos, from 2027-07-01. A 2026 go-live date is out of date."
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
"country": "IT",
|
|
93
|
+
"name": "Italy - FatturaPA / SdI",
|
|
94
|
+
"receive_from": "2019-01-01",
|
|
95
|
+
"issue_from": "2019-01-01",
|
|
96
|
+
"note": "Domestic invoices clear through the Sistema di Interscambio in the FatturaPA XML format. UBL and CII are not accepted for domestic Italian invoices."
|
|
97
|
+
}
|
|
98
|
+
],
|
|
99
|
+
"rules": [
|
|
100
|
+
{
|
|
101
|
+
"id": "EINV-SYNTAX",
|
|
102
|
+
"kind": "syntax",
|
|
103
|
+
"sev": "error",
|
|
104
|
+
"bt": "-",
|
|
105
|
+
"msg": "Root element is not an EN 16931 syntax",
|
|
106
|
+
"fix": "Use a UBL 2.1 Invoice/CreditNote or a UN/CEFACT CrossIndustryInvoice document."
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
"id": "EINV-BT-24",
|
|
110
|
+
"kind": "profile",
|
|
111
|
+
"sev": "error",
|
|
112
|
+
"bt": "BT-24",
|
|
113
|
+
"msg": "Specification identifier (CustomizationID) is missing",
|
|
114
|
+
"fix": "Add cbc:CustomizationID naming the EN 16931 CIUS this invoice follows."
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
"id": "EINV-PROFILE-UNKNOWN",
|
|
118
|
+
"kind": "profile_unknown",
|
|
119
|
+
"sev": "warn",
|
|
120
|
+
"bt": "BT-24",
|
|
121
|
+
"msg": "Specification identifier is not a profile this linter knows",
|
|
122
|
+
"fix": "Check the identifier against the CIUS your receiving platform publishes."
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
"id": "EINV-PROFILE-RETIRED",
|
|
126
|
+
"kind": "profile_retired",
|
|
127
|
+
"sev": "error",
|
|
128
|
+
"bt": "BT-24",
|
|
129
|
+
"msg": "Specification identifier names a retired profile version",
|
|
130
|
+
"fix": "Move to the replacement profile."
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
"id": "EINV-BT-1",
|
|
134
|
+
"kind": "present",
|
|
135
|
+
"sev": "error",
|
|
136
|
+
"bt": "BT-1",
|
|
137
|
+
"path": [
|
|
138
|
+
"cbc:ID"
|
|
139
|
+
],
|
|
140
|
+
"msg": "Invoice number (BT-1) is missing",
|
|
141
|
+
"fix": "Add cbc:ID at document level."
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
"id": "EINV-BT-2",
|
|
145
|
+
"kind": "present",
|
|
146
|
+
"sev": "error",
|
|
147
|
+
"bt": "BT-2",
|
|
148
|
+
"path": [
|
|
149
|
+
"cbc:IssueDate"
|
|
150
|
+
],
|
|
151
|
+
"msg": "Invoice issue date (BT-2) is missing",
|
|
152
|
+
"fix": "Add cbc:IssueDate in YYYY-MM-DD form."
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
"id": "EINV-BT-3",
|
|
156
|
+
"kind": "present",
|
|
157
|
+
"sev": "error",
|
|
158
|
+
"bt": "BT-3",
|
|
159
|
+
"path": [
|
|
160
|
+
"cbc:InvoiceTypeCode"
|
|
161
|
+
],
|
|
162
|
+
"msg": "Invoice type code (BT-3) is missing",
|
|
163
|
+
"fix": "Add cbc:InvoiceTypeCode, normally 380 for a commercial invoice."
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
"id": "EINV-BT-5",
|
|
167
|
+
"kind": "present",
|
|
168
|
+
"sev": "error",
|
|
169
|
+
"bt": "BT-5",
|
|
170
|
+
"path": [
|
|
171
|
+
"cbc:DocumentCurrencyCode"
|
|
172
|
+
],
|
|
173
|
+
"msg": "Invoice currency code (BT-5) is missing",
|
|
174
|
+
"fix": "Add cbc:DocumentCurrencyCode, for example EUR."
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
"id": "EINV-BT-27",
|
|
178
|
+
"kind": "present",
|
|
179
|
+
"sev": "error",
|
|
180
|
+
"bt": "BT-27",
|
|
181
|
+
"path": [
|
|
182
|
+
"cac:AccountingSupplierParty",
|
|
183
|
+
"cac:PartyLegalEntity",
|
|
184
|
+
"cbc:RegistrationName"
|
|
185
|
+
],
|
|
186
|
+
"msg": "Seller legal name (BT-27) is missing",
|
|
187
|
+
"fix": "Add cac:PartyLegalEntity/cbc:RegistrationName under the supplier party."
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
"id": "EINV-BT-31",
|
|
191
|
+
"kind": "present",
|
|
192
|
+
"sev": "error",
|
|
193
|
+
"bt": "BT-31",
|
|
194
|
+
"path": [
|
|
195
|
+
"cac:AccountingSupplierParty",
|
|
196
|
+
"cac:PartyTaxScheme",
|
|
197
|
+
"cbc:CompanyID"
|
|
198
|
+
],
|
|
199
|
+
"msg": "Seller VAT identifier (BT-31) is missing",
|
|
200
|
+
"fix": "Add cac:PartyTaxScheme/cbc:CompanyID with the VAT scheme for the seller."
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
"id": "EINV-BT-40",
|
|
204
|
+
"kind": "present",
|
|
205
|
+
"sev": "error",
|
|
206
|
+
"bt": "BT-40",
|
|
207
|
+
"path": [
|
|
208
|
+
"cac:AccountingSupplierParty",
|
|
209
|
+
"cac:PostalAddress",
|
|
210
|
+
"cbc:IdentificationCode"
|
|
211
|
+
],
|
|
212
|
+
"msg": "Seller country code (BT-40) is missing",
|
|
213
|
+
"fix": "Add cac:Country/cbc:IdentificationCode in the seller postal address."
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
"id": "EINV-BT-44",
|
|
217
|
+
"kind": "present",
|
|
218
|
+
"sev": "error",
|
|
219
|
+
"bt": "BT-44",
|
|
220
|
+
"path": [
|
|
221
|
+
"cac:AccountingCustomerParty",
|
|
222
|
+
"cac:PartyLegalEntity",
|
|
223
|
+
"cbc:RegistrationName"
|
|
224
|
+
],
|
|
225
|
+
"msg": "Buyer legal name (BT-44) is missing",
|
|
226
|
+
"fix": "Add cac:PartyLegalEntity/cbc:RegistrationName under the customer party."
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
"id": "EINV-BT-55",
|
|
230
|
+
"kind": "present",
|
|
231
|
+
"sev": "error",
|
|
232
|
+
"bt": "BT-55",
|
|
233
|
+
"path": [
|
|
234
|
+
"cac:AccountingCustomerParty",
|
|
235
|
+
"cac:PostalAddress",
|
|
236
|
+
"cbc:IdentificationCode"
|
|
237
|
+
],
|
|
238
|
+
"msg": "Buyer country code (BT-55) is missing",
|
|
239
|
+
"fix": "Add cac:Country/cbc:IdentificationCode in the buyer postal address."
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
"id": "EINV-BT-110",
|
|
243
|
+
"kind": "present",
|
|
244
|
+
"sev": "error",
|
|
245
|
+
"bt": "BT-110",
|
|
246
|
+
"path": [
|
|
247
|
+
"cac:TaxTotal",
|
|
248
|
+
"cbc:TaxAmount"
|
|
249
|
+
],
|
|
250
|
+
"msg": "Invoice total VAT amount (BT-110) is missing",
|
|
251
|
+
"fix": "Add cac:TaxTotal/cbc:TaxAmount at document level."
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
"id": "EINV-BT-109",
|
|
255
|
+
"kind": "present",
|
|
256
|
+
"sev": "error",
|
|
257
|
+
"bt": "BT-109",
|
|
258
|
+
"path": [
|
|
259
|
+
"cac:LegalMonetaryTotal",
|
|
260
|
+
"cbc:TaxExclusiveAmount"
|
|
261
|
+
],
|
|
262
|
+
"msg": "Invoice total without VAT (BT-109) is missing",
|
|
263
|
+
"fix": "Add cbc:TaxExclusiveAmount to cac:LegalMonetaryTotal."
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
"id": "EINV-BT-112",
|
|
267
|
+
"kind": "present",
|
|
268
|
+
"sev": "error",
|
|
269
|
+
"bt": "BT-112",
|
|
270
|
+
"path": [
|
|
271
|
+
"cac:LegalMonetaryTotal",
|
|
272
|
+
"cbc:TaxInclusiveAmount"
|
|
273
|
+
],
|
|
274
|
+
"msg": "Invoice total with VAT (BT-112) is missing",
|
|
275
|
+
"fix": "Add cbc:TaxInclusiveAmount to cac:LegalMonetaryTotal."
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
"id": "EINV-BT-115",
|
|
279
|
+
"kind": "present",
|
|
280
|
+
"sev": "error",
|
|
281
|
+
"bt": "BT-115",
|
|
282
|
+
"path": [
|
|
283
|
+
"cac:LegalMonetaryTotal",
|
|
284
|
+
"cbc:PayableAmount"
|
|
285
|
+
],
|
|
286
|
+
"msg": "Amount due for payment (BT-115) is missing",
|
|
287
|
+
"fix": "Add cbc:PayableAmount to cac:LegalMonetaryTotal."
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
"id": "EINV-LINE",
|
|
291
|
+
"kind": "line_count",
|
|
292
|
+
"sev": "error",
|
|
293
|
+
"bt": "BG-25",
|
|
294
|
+
"msg": "Invoice has no invoice line",
|
|
295
|
+
"fix": "An EN 16931 invoice needs at least one cac:InvoiceLine."
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
"id": "EINV-BR-CO-10",
|
|
299
|
+
"kind": "math_linesum",
|
|
300
|
+
"sev": "error",
|
|
301
|
+
"bt": "BT-106",
|
|
302
|
+
"msg": "Sum of invoice line net amounts (BT-106) does not equal the lines",
|
|
303
|
+
"fix": "Set cac:LegalMonetaryTotal/cbc:LineExtensionAmount to the sum of the line amounts."
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
"id": "EINV-BR-CO-15",
|
|
307
|
+
"kind": "math_inclusive",
|
|
308
|
+
"sev": "error",
|
|
309
|
+
"bt": "BT-112",
|
|
310
|
+
"msg": "Total with VAT (BT-112) does not equal total without VAT plus VAT",
|
|
311
|
+
"fix": "BT-112 must equal BT-109 + BT-110."
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
"id": "EINV-BR-CO-16",
|
|
315
|
+
"kind": "math_payable",
|
|
316
|
+
"sev": "error",
|
|
317
|
+
"bt": "BT-115",
|
|
318
|
+
"msg": "Amount due (BT-115) does not equal total with VAT minus prepaid plus rounding",
|
|
319
|
+
"fix": "BT-115 must equal BT-112 - BT-113 + BT-114."
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
"id": "EINV-BR-CO-25",
|
|
323
|
+
"kind": "due_date",
|
|
324
|
+
"sev": "warn",
|
|
325
|
+
"bt": "BT-9",
|
|
326
|
+
"msg": "Amount due is positive but neither a due date (BT-9) nor payment terms (BT-20) are given",
|
|
327
|
+
"fix": "Add cbc:DueDate or cac:PaymentTerms/cbc:Note."
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
"id": "EINV-FR-SIREN",
|
|
331
|
+
"kind": "country",
|
|
332
|
+
"country": "FR",
|
|
333
|
+
"sev": "error",
|
|
334
|
+
"bt": "BT-30",
|
|
335
|
+
"msg": "France: seller legal registration identifier (SIREN or SIRET) is missing or is not 9 or 14 digits",
|
|
336
|
+
"fix": "Add cac:AccountingSupplierParty/cac:PartyLegalEntity/cbc:CompanyID with schemeID 0009 (SIRET) or 0002 (SIREN)."
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
"id": "EINV-FR-BUYER-SIREN",
|
|
340
|
+
"kind": "country",
|
|
341
|
+
"country": "FR",
|
|
342
|
+
"sev": "error",
|
|
343
|
+
"bt": "BT-47",
|
|
344
|
+
"msg": "France: buyer legal registration identifier (SIREN or SIRET) is missing on a domestic B2B invoice",
|
|
345
|
+
"fix": "Add cac:AccountingCustomerParty/cac:PartyLegalEntity/cbc:CompanyID with the buyer SIREN or SIRET."
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
"id": "EINV-FR-MINIMUM",
|
|
349
|
+
"kind": "country",
|
|
350
|
+
"country": "FR",
|
|
351
|
+
"sev": "error",
|
|
352
|
+
"bt": "BT-24",
|
|
353
|
+
"msg": "France: the Factur-X MINIMUM profile is not an EN 16931 compliant invoice",
|
|
354
|
+
"fix": "Use Factur-X BASIC or EN 16931 (COMFORT), or UBL 2.1 / CII."
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
"id": "EINV-DE-LEITWEG",
|
|
358
|
+
"kind": "country",
|
|
359
|
+
"country": "DE",
|
|
360
|
+
"sev": "error",
|
|
361
|
+
"bt": "BT-10",
|
|
362
|
+
"msg": "XRechnung: buyer reference (BT-10, the Leitweg-ID) is missing",
|
|
363
|
+
"fix": "Add cbc:BuyerReference with the Leitweg-ID the buyer gave you."
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
"id": "EINV-DE-CONTACT",
|
|
367
|
+
"kind": "country",
|
|
368
|
+
"country": "DE",
|
|
369
|
+
"sev": "error",
|
|
370
|
+
"bt": "BT-41",
|
|
371
|
+
"msg": "XRechnung: seller contact name, telephone and email are required and one of them is missing",
|
|
372
|
+
"fix": "Add cac:Contact with cbc:Name, cbc:Telephone and cbc:ElectronicMail under the supplier party."
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
"id": "EINV-DE-PAYMENT",
|
|
376
|
+
"kind": "country",
|
|
377
|
+
"country": "DE",
|
|
378
|
+
"sev": "error",
|
|
379
|
+
"bt": "BG-16",
|
|
380
|
+
"msg": "XRechnung: payment instructions (BG-16) are missing",
|
|
381
|
+
"fix": "Add cac:PaymentMeans with cbc:PaymentMeansCode."
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
"id": "EINV-BE-PEPPOL",
|
|
385
|
+
"kind": "country",
|
|
386
|
+
"country": "BE",
|
|
387
|
+
"sev": "error",
|
|
388
|
+
"bt": "BT-34",
|
|
389
|
+
"msg": "Belgium: seller or buyer Peppol electronic address (cbc:EndpointID with a schemeID) is missing",
|
|
390
|
+
"fix": "Add cbc:EndpointID schemeID=\"0208\" (Belgian enterprise number) to both parties."
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
"id": "EINV-BE-PROFILE",
|
|
394
|
+
"kind": "country",
|
|
395
|
+
"country": "BE",
|
|
396
|
+
"sev": "warn",
|
|
397
|
+
"bt": "BT-24",
|
|
398
|
+
"msg": "Belgium: the reference format for the domestic B2B mandate is Peppol BIS Billing 3.0 and this invoice declares another profile",
|
|
399
|
+
"fix": "Set CustomizationID to the Peppol BIS Billing 3.0 identifier."
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
"id": "EINV-PL-KSEF",
|
|
403
|
+
"kind": "country",
|
|
404
|
+
"country": "PL",
|
|
405
|
+
"sev": "error",
|
|
406
|
+
"bt": "-",
|
|
407
|
+
"msg": "Poland: domestic invoices must be filed to KSeF in the FA(3) structure, not UBL or CII",
|
|
408
|
+
"fix": "Generate the FA(3) XML for KSeF; keep UBL only for cross-border Peppol exchange."
|
|
409
|
+
},
|
|
410
|
+
{
|
|
411
|
+
"id": "EINV-PL-FA2",
|
|
412
|
+
"kind": "pl_fa2",
|
|
413
|
+
"sev": "error",
|
|
414
|
+
"bt": "-",
|
|
415
|
+
"msg": "Poland: this document uses the FA(2) KSeF structure, which was replaced by FA(3)",
|
|
416
|
+
"fix": "Regenerate against the FA(3) schema."
|
|
417
|
+
},
|
|
418
|
+
{
|
|
419
|
+
"id": "EINV-IT-SDI",
|
|
420
|
+
"kind": "country",
|
|
421
|
+
"country": "IT",
|
|
422
|
+
"sev": "error",
|
|
423
|
+
"bt": "-",
|
|
424
|
+
"msg": "Italy: domestic invoices clear through SdI in FatturaPA format, not UBL or CII",
|
|
425
|
+
"fix": "Generate FatturaPA XML for domestic Italian invoices."
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
"id": "EINV-ES-VERIFACTU",
|
|
429
|
+
"kind": "country",
|
|
430
|
+
"country": "ES",
|
|
431
|
+
"sev": "info",
|
|
432
|
+
"bt": "-",
|
|
433
|
+
"msg": "Spain: Verifactu was postponed by one year - Corporate Income Tax payers from 2027-01-01 and other taxpayers from 2027-07-01",
|
|
434
|
+
"fix": "Check any 2026 Verifactu go-live date hard-coded in your billing configuration."
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
"id": "EINV-MANDATE-LIVE",
|
|
438
|
+
"kind": "mandate_note",
|
|
439
|
+
"sev": "info",
|
|
440
|
+
"bt": "-",
|
|
441
|
+
"msg": "Mandate status for the seller country",
|
|
442
|
+
"fix": "No change needed; this line states the dates the other findings are measured against."
|
|
443
|
+
}
|
|
444
|
+
]
|
|
445
|
+
}
|
package/strings.json
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "E-Invoice Mandate Lint - EU 2026",
|
|
3
|
+
"subtitle": "Lints UBL and CII invoice XML against EN 16931 and the national e-invoicing mandates that are live in 2026",
|
|
4
|
+
"bin": "einvoice-mandate-lint",
|
|
5
|
+
"price": 29,
|
|
6
|
+
"free": "Lint the invoice XML you have open - every EN 16931 core field plus the country mandate rules its CustomizationID and seller country select, reported with line numbers.",
|
|
7
|
+
"paid": "Scan every invoice XML in the workspace in one pass and export the findings as JSON, CSV or SARIF you keep and run in CI.",
|
|
8
|
+
"need_key": "This option needs a licence (E-Invoice Mandate Lint - EU 2026).",
|
|
9
|
+
"exts": []
|
|
10
|
+
}
|