einvoice/test/suite/einvoice_standards-compliance/test.std-01.en16931-core.ts
2025-05-26 04:04:51 +00:00

739 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();