einvoice/test/suite/einvoice_standards-compliance/test.std-01.en16931-core.ts

739 lines
23 KiB
TypeScript
Raw Normal View History

2025-05-26 04:04:51 +00:00
import { tap } from '@git.zone/tstest/tapbundle';
import * as plugins from '../plugins.js';
import { EInvoice } from '../../../ts/index.js';
import { PerformanceTracker } from '../performance.tracker.js';
import { CorpusLoader } from '../corpus.loader.js';
const performanceTracker = new PerformanceTracker('STD-01: EN16931 Core Compliance');
tap.test('STD-01: EN16931 Core Compliance - should validate EN16931 core standard compliance', async (t) => {
const einvoice = new EInvoice();
const corpusLoader = new CorpusLoader();
// Test 1: Mandatory fields validation
const mandatoryFieldsValidation = await performanceTracker.measureAsync(
'mandatory-fields-validation',
async () => {
const mandatoryFields = [
'BT-1', // Invoice number
'BT-2', // Invoice issue date
'BT-5', // Invoice currency code
'BT-6', // VAT accounting currency code
'BT-9', // Payment due date
'BT-24', // Specification identifier
'BT-27', // Buyer name
'BT-44', // Seller name
'BT-109', // Invoice line net amount
'BT-112', // Invoice total amount without VAT
'BT-115', // Amount due for payment
];
const testInvoices = [
{
name: 'complete-invoice',
xml: createCompleteEN16931Invoice()
},
{
name: 'missing-bt1',
xml: createEN16931InvoiceWithout('BT-1')
},
{
name: 'missing-bt27',
xml: createEN16931InvoiceWithout('BT-27')
},
{
name: 'missing-multiple',
xml: createEN16931InvoiceWithout(['BT-5', 'BT-44'])
}
];
const results = [];
for (const test of testInvoices) {
try {
const parsed = await einvoice.parseDocument(test.xml);
const validation = await einvoice.validateEN16931(parsed);
results.push({
invoice: test.name,
valid: validation?.isValid || false,
missingMandatory: validation?.missingMandatoryFields || [],
errors: validation?.errors || []
});
} catch (error) {
results.push({
invoice: test.name,
valid: false,
error: error.message
});
}
}
return results;
}
);
// Check complete invoice is valid
const completeInvoice = mandatoryFieldsValidation.find(r => r.invoice === 'complete-invoice');
t.ok(completeInvoice?.valid, 'Complete EN16931 invoice should be valid');
// Check missing fields are detected
mandatoryFieldsValidation.filter(r => r.invoice !== 'complete-invoice').forEach(result => {
t.notOk(result.valid, `Invoice ${result.invoice} should be invalid`);
t.ok(result.missingMandatory?.length > 0, 'Missing mandatory fields should be detected');
});
// Test 2: Business rules validation
const businessRulesValidation = await performanceTracker.measureAsync(
'business-rules-validation',
async () => {
const businessRuleTests = [
{
rule: 'BR-1',
description: 'Invoice shall have Specification identifier',
xml: createInvoiceViolatingBR('BR-1')
},
{
rule: 'BR-2',
description: 'Invoice shall have Invoice number',
xml: createInvoiceViolatingBR('BR-2')
},
{
rule: 'BR-3',
description: 'Invoice shall have Issue date',
xml: createInvoiceViolatingBR('BR-3')
},
{
rule: 'BR-CO-10',
description: 'Sum of line net amounts = Total without VAT',
xml: createInvoiceViolatingBR('BR-CO-10')
},
{
rule: 'BR-CO-15',
description: 'Total with VAT = Total without VAT + VAT',
xml: createInvoiceViolatingBR('BR-CO-15')
}
];
const results = [];
for (const test of businessRuleTests) {
try {
const parsed = await einvoice.parseDocument(test.xml);
const validation = await einvoice.validateEN16931BusinessRules(parsed);
const ruleViolated = validation?.violations?.find(v => v.rule === test.rule);
results.push({
rule: test.rule,
description: test.description,
violated: !!ruleViolated,
severity: ruleViolated?.severity || 'unknown',
message: ruleViolated?.message
});
} catch (error) {
results.push({
rule: test.rule,
error: error.message
});
}
}
return results;
}
);
businessRulesValidation.forEach(result => {
t.ok(result.violated, `Business rule ${result.rule} violation should be detected`);
});
// Test 3: Syntax bindings compliance
const syntaxBindingsCompliance = await performanceTracker.measureAsync(
'syntax-bindings-compliance',
async () => {
const syntaxTests = [
{
syntax: 'UBL',
version: '2.1',
xml: createUBLEN16931Invoice()
},
{
syntax: 'CII',
version: 'D16B',
xml: createCIIEN16931Invoice()
}
];
const results = [];
for (const test of syntaxTests) {
try {
const parsed = await einvoice.parseDocument(test.xml);
const compliance = await einvoice.checkEN16931SyntaxBinding(parsed, {
syntax: test.syntax,
version: test.version
});
results.push({
syntax: test.syntax,
version: test.version,
compliant: compliance?.isCompliant || false,
mappingComplete: compliance?.allFieldsMapped || false,
unmappedFields: compliance?.unmappedFields || [],
syntaxSpecificRules: compliance?.syntaxRulesPassed || false
});
} catch (error) {
results.push({
syntax: test.syntax,
version: test.version,
compliant: false,
error: error.message
});
}
}
return results;
}
);
syntaxBindingsCompliance.forEach(result => {
t.ok(result.compliant, `${result.syntax} syntax binding should be compliant`);
t.ok(result.mappingComplete, `All EN16931 fields should map to ${result.syntax}`);
});
// Test 4: Code list validation
const codeListValidation = await performanceTracker.measureAsync(
'code-list-validation',
async () => {
const codeListTests = [
{
field: 'BT-5',
name: 'Currency code',
validCode: 'EUR',
invalidCode: 'XXX'
},
{
field: 'BT-40',
name: 'Country code',
validCode: 'DE',
invalidCode: 'ZZ'
},
{
field: 'BT-48',
name: 'VAT category code',
validCode: 'S',
invalidCode: 'X'
},
{
field: 'BT-81',
name: 'Payment means code',
validCode: '30',
invalidCode: '99'
},
{
field: 'BT-130',
name: 'Unit of measure',
validCode: 'C62',
invalidCode: 'XXX'
}
];
const results = [];
for (const test of codeListTests) {
// Test valid code
const validInvoice = createInvoiceWithCode(test.field, test.validCode);
const validResult = await einvoice.validateEN16931CodeLists(validInvoice);
// Test invalid code
const invalidInvoice = createInvoiceWithCode(test.field, test.invalidCode);
const invalidResult = await einvoice.validateEN16931CodeLists(invalidInvoice);
results.push({
field: test.field,
name: test.name,
validCodeAccepted: validResult?.isValid || false,
invalidCodeRejected: !invalidResult?.isValid,
codeListUsed: validResult?.codeListVersion
});
}
return results;
}
);
codeListValidation.forEach(result => {
t.ok(result.validCodeAccepted, `Valid ${result.name} should be accepted`);
t.ok(result.invalidCodeRejected, `Invalid ${result.name} should be rejected`);
});
// Test 5: Calculation rules
const calculationRules = await performanceTracker.measureAsync(
'calculation-rules-validation',
async () => {
const calculationTests = [
{
name: 'line-extension-amount',
rule: 'BT-131 = BT-129 × BT-130',
values: { quantity: 10, price: 50.00, expected: 500.00 }
},
{
name: 'invoice-total-without-vat',
rule: 'BT-109 sum = BT-112',
lineAmounts: [100.00, 200.00, 150.00],
expected: 450.00
},
{
name: 'invoice-total-with-vat',
rule: 'BT-112 + BT-110 = BT-113',
values: { netTotal: 1000.00, vatAmount: 190.00, expected: 1190.00 }
},
{
name: 'vat-calculation',
rule: 'BT-116 × (BT-119/100) = BT-117',
values: { taxableAmount: 1000.00, vatRate: 19.00, expected: 190.00 }
}
];
const results = [];
for (const test of calculationTests) {
const invoice = createInvoiceWithCalculation(test);
const validation = await einvoice.validateEN16931Calculations(invoice);
const calculationResult = validation?.calculations?.find(c => c.rule === test.rule);
results.push({
name: test.name,
rule: test.rule,
correct: calculationResult?.isCorrect || false,
calculated: calculationResult?.calculatedValue,
expected: calculationResult?.expectedValue,
tolerance: calculationResult?.tolerance || 0.01
});
}
return results;
}
);
calculationRules.forEach(result => {
t.ok(result.correct, `Calculation ${result.name} should be correct`);
});
// Test 6: Conditional fields
const conditionalFields = await performanceTracker.measureAsync(
'conditional-fields-validation',
async () => {
const conditionalTests = [
{
condition: 'If BT-31 exists, then BT-32 is mandatory',
scenario: 'seller-tax-representative',
fields: { 'BT-31': 'Tax Rep Name', 'BT-32': null }
},
{
condition: 'If BT-7 != BT-2, then BT-7 is allowed',
scenario: 'tax-point-date',
fields: { 'BT-2': '2024-01-15', 'BT-7': '2024-01-20' }
},
{
condition: 'If credit note, BT-3 must be 381',
scenario: 'credit-note-type',
fields: { 'BT-3': '380', isCreditNote: true }
}
];
const results = [];
for (const test of conditionalTests) {
const invoice = createInvoiceWithConditional(test);
const validation = await einvoice.validateEN16931Conditionals(invoice);
results.push({
condition: test.condition,
scenario: test.scenario,
valid: validation?.isValid || false,
conditionMet: validation?.conditionsMet?.includes(test.condition),
errors: validation?.conditionalErrors || []
});
}
return results;
}
);
conditionalFields.forEach(result => {
if (result.scenario === 'tax-point-date') {
t.ok(result.valid, 'Valid conditional field should be accepted');
} else {
t.notOk(result.valid, `Invalid conditional ${result.scenario} should be rejected`);
}
});
// Test 7: Corpus EN16931 compliance testing
const corpusCompliance = await performanceTracker.measureAsync(
'corpus-en16931-compliance',
async () => {
const en16931Files = await corpusLoader.getFilesByPattern('**/EN16931*.xml');
const sampleSize = Math.min(10, en16931Files.length);
const samples = en16931Files.slice(0, sampleSize);
const results = {
total: samples.length,
compliant: 0,
nonCompliant: 0,
errors: []
};
for (const file of samples) {
try {
const content = await corpusLoader.readFile(file);
const parsed = await einvoice.parseDocument(content);
const validation = await einvoice.validateEN16931(parsed);
if (validation?.isValid) {
results.compliant++;
} else {
results.nonCompliant++;
results.errors.push({
file: file.name,
errors: validation?.errors?.slice(0, 3) // First 3 errors
});
}
} catch (error) {
results.errors.push({
file: file.name,
error: error.message
});
}
}
return results;
}
);
t.ok(corpusCompliance.compliant > 0, 'Some corpus files should be EN16931 compliant');
// Test 8: Profile validation
const profileValidation = await performanceTracker.measureAsync(
'en16931-profile-validation',
async () => {
const profiles = [
{
name: 'BASIC',
level: 'Minimum',
requiredFields: ['BT-1', 'BT-2', 'BT-5', 'BT-27', 'BT-44']
},
{
name: 'COMFORT',
level: 'Basic+',
requiredFields: ['BT-1', 'BT-2', 'BT-5', 'BT-27', 'BT-44', 'BT-50', 'BT-51']
},
{
name: 'EXTENDED',
level: 'Full',
requiredFields: null // All fields allowed
}
];
const results = [];
for (const profile of profiles) {
const invoice = createEN16931InvoiceForProfile(profile.name);
const validation = await einvoice.validateEN16931Profile(invoice, profile.name);
results.push({
profile: profile.name,
level: profile.level,
valid: validation?.isValid || false,
profileCompliant: validation?.profileCompliant || false,
fieldCoverage: validation?.fieldCoverage || 0
});
}
return results;
}
);
profileValidation.forEach(result => {
t.ok(result.valid, `Profile ${result.profile} should validate`);
});
// Test 9: Extension handling
const extensionHandling = await performanceTracker.measureAsync(
'en16931-extension-handling',
async () => {
const extensionTests = [
{
name: 'national-extension',
type: 'DE-specific',
xml: createEN16931WithExtension('national')
},
{
name: 'sector-extension',
type: 'Construction',
xml: createEN16931WithExtension('sector')
},
{
name: 'custom-extension',
type: 'Company-specific',
xml: createEN16931WithExtension('custom')
}
];
const results = [];
for (const test of extensionTests) {
try {
const parsed = await einvoice.parseDocument(test.xml);
const validation = await einvoice.validateEN16931WithExtensions(parsed);
results.push({
extension: test.name,
type: test.type,
coreValid: validation?.coreCompliant || false,
extensionValid: validation?.extensionValid || false,
extensionPreserved: validation?.extensionDataPreserved || false
});
} catch (error) {
results.push({
extension: test.name,
type: test.type,
error: error.message
});
}
}
return results;
}
);
extensionHandling.forEach(result => {
t.ok(result.coreValid, `Core EN16931 should remain valid with ${result.extension}`);
t.ok(result.extensionPreserved, 'Extension data should be preserved');
});
// Test 10: Semantic validation
const semanticValidation = await performanceTracker.measureAsync(
'en16931-semantic-validation',
async () => {
const semanticTests = [
{
name: 'date-logic',
test: 'Issue date before due date',
valid: { issueDate: '2024-01-15', dueDate: '2024-02-15' },
invalid: { issueDate: '2024-02-15', dueDate: '2024-01-15' }
},
{
name: 'amount-signs',
test: 'Credit note amounts negative',
valid: { type: '381', amount: -100.00 },
invalid: { type: '381', amount: 100.00 }
},
{
name: 'tax-logic',
test: 'VAT rate matches category',
valid: { category: 'S', rate: 19.00 },
invalid: { category: 'Z', rate: 19.00 }
}
];
const results = [];
for (const test of semanticTests) {
// Test valid scenario
const validInvoice = createInvoiceWithSemantic(test.valid);
const validResult = await einvoice.validateEN16931Semantics(validInvoice);
// Test invalid scenario
const invalidInvoice = createInvoiceWithSemantic(test.invalid);
const invalidResult = await einvoice.validateEN16931Semantics(invalidInvoice);
results.push({
name: test.name,
test: test.test,
validAccepted: validResult?.isValid || false,
invalidRejected: !invalidResult?.isValid,
semanticErrors: invalidResult?.semanticErrors || []
});
}
return results;
}
);
semanticValidation.forEach(result => {
t.ok(result.validAccepted, `Valid semantic ${result.name} should be accepted`);
t.ok(result.invalidRejected, `Invalid semantic ${result.name} should be rejected`);
});
// Print performance summary
performanceTracker.printSummary();
});
// Helper functions
function createCompleteEN16931Invoice(): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"
xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2">
<cbc:CustomizationID>urn:cen.eu:en16931:2017</cbc:CustomizationID>
<cbc:ID>INV-001</cbc:ID>
<cbc:IssueDate>2024-01-15</cbc:IssueDate>
<cbc:DueDate>2024-02-15</cbc:DueDate>
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
<cac:AccountingSupplierParty>
<cac:Party>
<cac:PartyName>
<cbc:Name>Seller Company</cbc:Name>
</cac:PartyName>
</cac:Party>
</cac:AccountingSupplierParty>
<cac:AccountingCustomerParty>
<cac:Party>
<cac:PartyName>
<cbc:Name>Buyer Company</cbc:Name>
</cac:PartyName>
</cac:Party>
</cac:AccountingCustomerParty>
<cac:LegalMonetaryTotal>
<cbc:LineExtensionAmount currencyID="EUR">1000.00</cbc:LineExtensionAmount>
<cbc:TaxExclusiveAmount currencyID="EUR">1000.00</cbc:TaxExclusiveAmount>
<cbc:TaxInclusiveAmount currencyID="EUR">1190.00</cbc:TaxInclusiveAmount>
<cbc:PayableAmount currencyID="EUR">1190.00</cbc:PayableAmount>
</cac:LegalMonetaryTotal>
<cac:InvoiceLine>
<cbc:ID>1</cbc:ID>
<cbc:InvoicedQuantity unitCode="C62">10</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">1000.00</cbc:LineExtensionAmount>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">100.00</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
</Invoice>`;
}
function createEN16931InvoiceWithout(fields: string | string[]): string {
// Create invoice missing specified fields
const fieldsToOmit = Array.isArray(fields) ? fields : [fields];
let invoice = createCompleteEN16931Invoice();
// Remove fields based on BT codes
if (fieldsToOmit.includes('BT-1')) {
invoice = invoice.replace(/<cbc:ID>.*?<\/cbc:ID>/, '');
}
if (fieldsToOmit.includes('BT-5')) {
invoice = invoice.replace(/<cbc:DocumentCurrencyCode>.*?<\/cbc:DocumentCurrencyCode>/, '');
}
// ... etc
return invoice;
}
function createInvoiceViolatingBR(rule: string): string {
// Create invoices that violate specific business rules
const base = createCompleteEN16931Invoice();
switch (rule) {
case 'BR-CO-10':
// Sum of lines != total
return base.replace('<cbc:LineExtensionAmount currencyID="EUR">1000.00</cbc:LineExtensionAmount>',
'<cbc:LineExtensionAmount currencyID="EUR">900.00</cbc:LineExtensionAmount>');
case 'BR-CO-15':
// Total with VAT != Total without VAT + VAT
return base.replace('<cbc:TaxInclusiveAmount currencyID="EUR">1190.00</cbc:TaxInclusiveAmount>',
'<cbc:TaxInclusiveAmount currencyID="EUR">1200.00</cbc:TaxInclusiveAmount>');
default:
return base;
}
}
function createUBLEN16931Invoice(): string {
return createCompleteEN16931Invoice();
}
function createCIIEN16931Invoice(): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<rsm:CrossIndustryInvoice xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100">
<rsm:ExchangedDocumentContext>
<ram:GuidelineSpecifiedDocumentContextParameter>
<ram:ID>urn:cen.eu:en16931:2017</ram:ID>
</ram:GuidelineSpecifiedDocumentContextParameter>
</rsm:ExchangedDocumentContext>
<rsm:ExchangedDocument>
<ram:ID>INV-001</ram:ID>
<ram:TypeCode>380</ram:TypeCode>
<ram:IssueDateTime>
<udt:DateTimeString format="102">20240115</udt:DateTimeString>
</ram:IssueDateTime>
</rsm:ExchangedDocument>
</rsm:CrossIndustryInvoice>`;
}
function createInvoiceWithCode(field: string, code: string): any {
// Return invoice object with specific code
return {
currencyCode: field === 'BT-5' ? code : 'EUR',
countryCode: field === 'BT-40' ? code : 'DE',
vatCategoryCode: field === 'BT-48' ? code : 'S',
paymentMeansCode: field === 'BT-81' ? code : '30',
unitCode: field === 'BT-130' ? code : 'C62'
};
}
function createInvoiceWithCalculation(test: any): any {
// Create invoice with specific calculation scenario
return {
lines: test.lineAmounts?.map(amount => ({ netAmount: amount })),
totals: {
netTotal: test.values?.netTotal,
vatAmount: test.values?.vatAmount,
grossTotal: test.values?.expected
}
};
}
function createInvoiceWithConditional(test: any): any {
// Create invoice with conditional field scenario
return {
...test.fields,
documentType: test.isCreditNote ? 'CreditNote' : 'Invoice'
};
}
function createEN16931InvoiceForProfile(profile: string): string {
// Create invoice matching specific profile requirements
if (profile === 'BASIC') {
return createEN16931InvoiceWithout(['BT-50', 'BT-51']); // Remove optional fields
}
return createCompleteEN16931Invoice();
}
function createEN16931WithExtension(type: string): string {
const base = createCompleteEN16931Invoice();
const extension = type === 'national' ?
'<ext:GermanSpecificField>Value</ext:GermanSpecificField>' :
'<ext:CustomField>Value</ext:CustomField>';
return base.replace('</Invoice>', `${extension}</Invoice>`);
}
function createInvoiceWithSemantic(scenario: any): any {
return {
issueDate: scenario.issueDate,
dueDate: scenario.dueDate,
documentType: scenario.type,
totalAmount: scenario.amount,
vatCategory: scenario.category,
vatRate: scenario.rate
};
}
// Run the test
tap.start();