Rulesets and extensions
A ruleset is a constant that holds rules. You validate against an ordered list of rulesets, and each constant in the list builds on the ones before it, as later entries of an ESLint flat config do. This page covers the built-in rulesets, how a list is composed, and how to write your own: custom rules, disabling and softening rules, defaults, and registration for a customization id.
The built-in rulesets
| Constant | Id | Requires | Rules |
|---|---|---|---|
en16931Ruleset | en16931 | - | EN 16931: BR-*, and the UBL-* rules the model can show |
peppolRuleset | peppol | en16931Ruleset | Peppol BIS Billing 3.0: PEPPOL-EN16931-*, PEPPOL-COMMON-* |
dkRuleset | dk | peppolRuleset | Danish national rules DK-R-*, for suppliers with a Danish address |
Compose a list
import {
createInvoice,
dkRuleset,
en16931Ruleset,
peppolRuleset,
validate,
} from '@facturometro/einvoice';
import { baseInvoice } from '../base.ts';
const danishInvoice = createInvoice({
...baseInvoice,
accountingSupplierParty: {
party: {
endpointId: { schemeId: '0184', value: '12345674' },
postalAddress: { cityName: 'København', country: { identificationCode: 'DK' } },
partyTaxScheme: [{ companyId: 'DK12345674' }],
partyLegalEntity: {
registrationName: 'Leverandør ApS',
companyId: { schemeId: '0184', value: '12345674' },
},
},
},
});
// Each constant must come after the one it requires: dk after peppol, peppol after en16931.
export const ruleset = [en16931Ruleset, peppolRuleset, dkRuleset];
const result = validate(danishInvoice, { ruleset });
const ran = result.ruleset.ids; // ['en16931', 'peppol', 'dk']requires means "must appear earlier in the list". A list that leaves out a requirement, puts it later ([peppolRuleset, en16931Ruleset]), names a constant twice or is empty throws a RulesetError. Rules run in list order, so issues are reported in that order too, and result.ruleset names the composition that ran.
A company ruleset
Say ACME, a buyer, wants every invoice to carry its purchase order number, does not care about a missing supporting document id, and receives documents from an old export that leaves out the profile id. That is one constant, made with defineRuleset() and rule.custom():
import {
createInvoice,
defineRuleset,
en16931Ruleset,
PEPPOL_BIS_BILLING_3_CUSTOMIZATION_ID,
PEPPOL_BIS_BILLING_3_PROFILE_ID,
peppolRuleset,
registerRuleset,
rule,
validate,
} from '@facturometro/einvoice';
import { baseInvoice } from '../base.ts';
export const acme = defineRuleset({
id: 'acme',
version: '1.0.0',
requires: [peppolRuleset],
rules: [
rule.custom('ACME-R-001', {
severity: 'fatal',
message: 'An ACME invoice must carry the purchase order reference (BT-13).',
bt: ['BT-13'],
at: 'document',
test: (document, ctx) =>
ctx.normSpace(document.orderReference?.id) !== '' || { at: ['orderReference'] },
}),
],
// ACME-R-001 already asks for more than PEPPOL-EN16931-R003 (buyer reference OR order reference).
disable: ['PEPPOL-EN16931-R003'],
// A supporting document without an id is tolerated, but still reported.
override: { 'BR-52': { severity: 'warning' } },
// Older ACME exports leave out the profile id: default it before the rules run.
normalize: (document) =>
document.profileId === undefined
? { ...document, profileId: PEPPOL_BIS_BILLING_3_PROFILE_ID }
: document,
});Each constant in a list is applied in turn:
rulesare added.rule.custom()takes the severity and message inline and refuses the ids of the specification;atanchors the rule at a model path ('document','invoiceLine[]'), andtestreturnstrue,false, or{ at, data }to point at the offending element.disableremoves rules defined earlier in the list, by id or glob ('BR-CO-*'). An entry that matches nothing throws.overridepatchesseverity,message,test,whenorcontextof rules defined earlier. When two constants patch the same field, the later one wins.
normalize hooks run before the rules, in list order, on the document being validated.
const input = { ...baseInvoice, orderReference: { id: 'PO-4711' } };
const explicit = validate(createInvoice(input), {
ruleset: [en16931Ruleset, peppolRuleset, acme],
});disable cannot be undone
Once a constant disables a rule, no later constant can bring it back: an override of it throws, and so does a constant that defines a rule with the same id. To change what a rule checks, list it in override with a new test instead of disabling it.
Register it for a customization id
Documents that declare ACME's customization id can pick up the composition without a ruleset option:
const ACME_CUSTOMIZATION_ID = `${PEPPOL_BIS_BILLING_3_CUSTOMIZATION_ID}#conformant#urn:acme.example:cius:1.0`;
registerRuleset({
customizationId: ACME_CUSTOMIZATION_ID,
ruleset: [en16931Ruleset, peppolRuleset, acme],
});
const acmeInvoice = createInvoice({ ...input, customizationId: ACME_CUSTOMIZATION_ID });
const resolved = validate(acmeInvoice).ruleset.ids; // ['en16931', 'peppol', 'acme']
const withoutOrder = createInvoice({ ...baseInvoice, customizationId: ACME_CUSTOMIZATION_ID });
const failing = validate(withoutOrder).fatal.map((issue) => issue.id); // ['ACME-R-001']registerRuleset() compiles the list immediately, so a broken composition fails when you register it. A document's rules are resolved in this order: the ruleset option, an exact customization id match, the longest registered prefix among registrations with matchPrefix: true, then the default. The built-in Peppol registration matches by prefix, so an exact registration of a suffixed Peppol id wins over it. Registering the same id twice throws.
Structural rules
The UBL-CR, UBL-SR and UBL-DT rules are about the XML: elements the Peppol subset does not use, elements that occur too often, attributes that are not allowed. The parser reports most of them. Every composition sits on a base layer that defines these ids, so disable and override reach them like any other rule, and validateXml() applies the result to the parser's diagnostics:
import { defineRuleset, en16931Ruleset, peppolRuleset, validateXml } from '@facturometro/einvoice';
import { xml } from '../first-invoice/create.ts';
// The same invoice with an element the Peppol subset of UBL does not use.
const withCopyIndicator = xml.replace(
'</cbc:ID>',
'</cbc:ID>\n <cbc:CopyIndicator>false</cbc:CopyIndicator>',
);
const byDefault = validateXml(withCopyIndicator).warnings.map((issue) => issue.id); // ['UBL-CR-004']
const lenient = defineRuleset({ id: 'lenient', version: '1.0.0', disable: ['UBL-CR-*'] });
const quiet = validateXml(withCopyIndicator, {
ruleset: [en16931Ruleset, peppolRuleset, lenient],
}).warnings; // []For a rule the parser checks, override may change only severity and message. The library's own EINV-* ids (EINV-XML-*, EINV-XSD-LEXICAL, EINV-RULE-ERROR) report data it could not read or check and cannot be configured: a disable or override entry that can match one, 'EINV-*' and '*' included, throws. result.diagnostics always keeps the parser's report unfiltered.
Disabling a UBL-SR rule hides dropped data
When an element occurs more often than the syntax allows, the parser keeps what fits, drops the rest and reports the UBL-SR rule for that limit. It reports EINV-XML-CARDINALITY only where no UBL-SR rule covers the element. If your composition disables such a UBL-SR id or makes it a warning, validateXml() can return ok: true for a document whose model silently lost elements. Keep the UBL-SR rules fatal, or check result.diagnostics yourself.
Rules that read allowances and charges
A cbc:ChargeIndicator that is not an XSD boolean (TRUE, yes, an empty element) is reported once as fatal EINV-XSD-LEXICAL. The document view's selectors, docAllowances(), docCharges(), lineAllowances() and lineCharges(), take { unreadable } to say what to do with such an allowance or charge:
'exclude', the default, leaves it out of the selection. That suits a rule about each allowance or charge on its own.'throw'makes the selection throw, and the engine skips the rule's test for that node instead of reporting a failure. A rule that sums a selection must use it, or it computes a short total.
import {
createInvoice,
Decimal,
defineRuleset,
en16931Ruleset,
peppolRuleset,
rule,
validate,
} from '@facturometro/einvoice';
import { baseInvoice } from '../base.ts';
const chargeCap = rule.custom('ACME-R-002', {
severity: 'warning',
message: 'Document level charges should not exceed 100.00 (BT-99).',
bt: ['BT-99'],
at: 'document',
test: (_document, ctx) => {
// `throw`: a cbc:ChargeIndicator that is not a boolean skips this test (it is already reported
// as EINV-XSD-LEXICAL) instead of silently leaving a charge out of the sum.
const charges = ctx.view.docCharges('cen', { unreadable: 'throw' });
const total = ctx.sum(charges.map((charge) => charge.node.amount));
return total.le(Decimal.from(100)) || { data: { total: total.toString() } };
},
});
export const ruleset = [
en16931Ruleset,
peppolRuleset,
defineRuleset({
id: 'acme-charges',
version: '1.0.0',
requires: [peppolRuleset],
rules: [chargeCap],
}),
];Your own casts behave the same way: ctx.dec() of a value that was reported as EINV-XSD-LEXICAL throws, and the engine skips the test. Conventions for rules contributed to this library are in Contributing.