feat(validation): Implement EN16931 compliance validation types and VAT categories
- Added validation types for EN16931 compliance in `validation.types.ts`, including interfaces for `ValidationResult`, `ValidationOptions`, and `ValidationReport`. - Introduced `VATCategoriesValidator` in `vat-categories.validator.ts` to validate VAT categories according to EN16931 rules, including detailed checks for standard, zero-rated, exempt, reverse charge, intra-community, export, and out-of-scope services. - Enhanced `IEInvoiceMetadata` interface in `en16931-metadata.ts` to include additional fields required for full standards compliance, such as delivery information, payment information, allowances, and charges. - Implemented helper methods for VAT calculations and validation logic to ensure accurate compliance with EN16931 standards.
This commit is contained in:
172
test/test.conformance-harness.ts
Normal file
172
test/test.conformance-harness.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle/index.js';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
// Import conformance harness
|
||||
import { ConformanceTestHarness, runConformanceTests } from '../ts/formats/validation/conformance.harness.js';
|
||||
|
||||
tap.test('Conformance Test Harness - initialization', async () => {
|
||||
const harness = new ConformanceTestHarness();
|
||||
expect(harness).toBeInstanceOf(ConformanceTestHarness);
|
||||
});
|
||||
|
||||
tap.test('Conformance Test Harness - load test samples', async () => {
|
||||
const harness = new ConformanceTestHarness();
|
||||
|
||||
// Check if test-samples directory exists
|
||||
const samplesDir = path.join(process.cwd(), 'test-samples');
|
||||
if (fs.existsSync(samplesDir)) {
|
||||
await harness.loadTestSamples(samplesDir);
|
||||
console.log('Test samples loaded successfully');
|
||||
} else {
|
||||
console.log('Test samples directory not found - skipping');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('Conformance Test Harness - run minimal test', async (tools) => {
|
||||
const harness = new ConformanceTestHarness();
|
||||
|
||||
// Create a minimal test sample
|
||||
const minimalUBL = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
|
||||
xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
|
||||
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">
|
||||
<cbc:CustomizationID>urn:cen.eu:en16931:2017</cbc:CustomizationID>
|
||||
<cbc:ID>TEST-001</cbc:ID>
|
||||
<cbc:IssueDate>2025-01-11</cbc:IssueDate>
|
||||
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
|
||||
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
|
||||
<cac:AccountingSupplierParty>
|
||||
<cac:Party>
|
||||
<cac:PartyName>
|
||||
<cbc:Name>Test Seller</cbc:Name>
|
||||
</cac:PartyName>
|
||||
<cac:PostalAddress>
|
||||
<cbc:StreetName>Test Street 1</cbc:StreetName>
|
||||
<cbc:CityName>Test City</cbc:CityName>
|
||||
<cbc:PostalZone>12345</cbc:PostalZone>
|
||||
<cac:Country>
|
||||
<cbc:IdentificationCode>DE</cbc:IdentificationCode>
|
||||
</cac:Country>
|
||||
</cac:PostalAddress>
|
||||
</cac:Party>
|
||||
</cac:AccountingSupplierParty>
|
||||
<cac:AccountingCustomerParty>
|
||||
<cac:Party>
|
||||
<cac:PartyName>
|
||||
<cbc:Name>Test Buyer</cbc:Name>
|
||||
</cac:PartyName>
|
||||
<cac:PostalAddress>
|
||||
<cbc:StreetName>Test Street 2</cbc:StreetName>
|
||||
<cbc:CityName>Test City</cbc:CityName>
|
||||
<cbc:PostalZone>54321</cbc:PostalZone>
|
||||
<cac:Country>
|
||||
<cbc:IdentificationCode>DE</cbc:IdentificationCode>
|
||||
</cac:Country>
|
||||
</cac:PostalAddress>
|
||||
</cac:Party>
|
||||
</cac:AccountingCustomerParty>
|
||||
<cac:TaxTotal>
|
||||
<cbc:TaxAmount currencyID="EUR">19.00</cbc:TaxAmount>
|
||||
<cac:TaxSubtotal>
|
||||
<cbc:TaxableAmount currencyID="EUR">100.00</cbc:TaxableAmount>
|
||||
<cbc:TaxAmount currencyID="EUR">19.00</cbc:TaxAmount>
|
||||
<cac:TaxCategory>
|
||||
<cbc:ID>S</cbc:ID>
|
||||
<cbc:Percent>19</cbc:Percent>
|
||||
</cac:TaxCategory>
|
||||
</cac:TaxSubtotal>
|
||||
</cac:TaxTotal>
|
||||
<cac:LegalMonetaryTotal>
|
||||
<cbc:TaxExclusiveAmount currencyID="EUR">100.00</cbc:TaxExclusiveAmount>
|
||||
<cbc:TaxInclusiveAmount currencyID="EUR">119.00</cbc:TaxInclusiveAmount>
|
||||
</cac:LegalMonetaryTotal>
|
||||
<cac:InvoiceLine>
|
||||
<cbc:ID>1</cbc:ID>
|
||||
<cbc:InvoicedQuantity unitCode="C62">1</cbc:InvoicedQuantity>
|
||||
<cbc:LineExtensionAmount currencyID="EUR">100.00</cbc:LineExtensionAmount>
|
||||
<cac:Item>
|
||||
<cbc:Name>Test Product</cbc:Name>
|
||||
<cac:ClassifiedTaxCategory>
|
||||
<cbc:ID>S</cbc:ID>
|
||||
<cbc:Percent>19</cbc:Percent>
|
||||
</cac:ClassifiedTaxCategory>
|
||||
</cac:Item>
|
||||
<cac:Price>
|
||||
<cbc:PriceAmount currencyID="EUR">100.00</cbc:PriceAmount>
|
||||
</cac:Price>
|
||||
</cac:InvoiceLine>
|
||||
</Invoice>`;
|
||||
|
||||
// Create temporary test directory
|
||||
const tempDir = path.join(process.cwd(), '.nogit', 'test-conformance');
|
||||
if (!fs.existsSync(tempDir)) {
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Write test file
|
||||
const testFile = path.join(tempDir, 'minimal-test.xml');
|
||||
fs.writeFileSync(testFile, minimalUBL);
|
||||
|
||||
// Create test sample metadata
|
||||
const testSamples = [{
|
||||
id: 'minimal-test',
|
||||
name: 'minimal-test.xml',
|
||||
path: testFile,
|
||||
format: 'UBL' as const,
|
||||
standard: 'EN16931',
|
||||
expectedValid: false, // We expect some validation errors
|
||||
description: 'Minimal test invoice'
|
||||
}];
|
||||
|
||||
// Load test samples manually
|
||||
(harness as any).testSamples = testSamples;
|
||||
|
||||
// Run conformance test
|
||||
await harness.runConformanceTests();
|
||||
|
||||
// Generate coverage matrix
|
||||
const coverage = harness.generateCoverageMatrix();
|
||||
console.log(`Coverage: ${coverage.coveragePercentage.toFixed(1)}%`);
|
||||
console.log(`Rules covered: ${coverage.coveredRules}/${coverage.totalRules}`);
|
||||
|
||||
// Clean up
|
||||
fs.unlinkSync(testFile);
|
||||
});
|
||||
|
||||
tap.test('Conformance Test Harness - coverage report generation', async () => {
|
||||
const harness = new ConformanceTestHarness();
|
||||
|
||||
// Generate empty coverage report
|
||||
const coverage = harness.generateCoverageMatrix();
|
||||
|
||||
expect(coverage.totalRules).toBeGreaterThan(100);
|
||||
expect(coverage.coveredRules).toBeGreaterThanOrEqual(0);
|
||||
expect(coverage.coveragePercentage).toBeGreaterThanOrEqual(0);
|
||||
expect(coverage.byCategory.document.total).toBeGreaterThan(0);
|
||||
expect(coverage.byCategory.calculation.total).toBeGreaterThan(0);
|
||||
expect(coverage.byCategory.vat.total).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('Conformance Test Harness - full test suite', async (tools) => {
|
||||
tools.timeout(60000); // 60 seconds timeout for full test
|
||||
|
||||
const samplesDir = path.join(process.cwd(), 'test-samples');
|
||||
if (!fs.existsSync(samplesDir)) {
|
||||
console.log('Test samples not found - skipping full conformance test');
|
||||
console.log('Run: npm run download-test-samples');
|
||||
return;
|
||||
}
|
||||
|
||||
// Run full conformance test
|
||||
console.log('\n=== Running Full Conformance Test Suite ===\n');
|
||||
await runConformanceTests(samplesDir, true);
|
||||
|
||||
// Check if HTML report was generated
|
||||
const reportPath = path.join(process.cwd(), 'coverage-report.html');
|
||||
if (fs.existsSync(reportPath)) {
|
||||
console.log(`\n✅ HTML report generated: ${reportPath}`);
|
||||
}
|
||||
});
|
||||
|
||||
export default tap;
|
128
test/test.currency-utils.ts
Normal file
128
test/test.currency-utils.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import {
|
||||
getCurrencyMinorUnits,
|
||||
roundToCurrency,
|
||||
getCurrencyTolerance,
|
||||
areMonetaryValuesEqual,
|
||||
CurrencyCalculator,
|
||||
RoundingMode
|
||||
} from '../ts/formats/utils/currency.utils.js';
|
||||
|
||||
tap.test('Currency Utils - should handle different currency decimal places', async () => {
|
||||
// Standard 2 decimal currencies
|
||||
expect(getCurrencyMinorUnits('EUR')).toEqual(2);
|
||||
expect(getCurrencyMinorUnits('USD')).toEqual(2);
|
||||
expect(getCurrencyMinorUnits('GBP')).toEqual(2);
|
||||
|
||||
// Zero decimal currencies
|
||||
expect(getCurrencyMinorUnits('JPY')).toEqual(0);
|
||||
expect(getCurrencyMinorUnits('KRW')).toEqual(0);
|
||||
|
||||
// Three decimal currencies
|
||||
expect(getCurrencyMinorUnits('KWD')).toEqual(3);
|
||||
expect(getCurrencyMinorUnits('TND')).toEqual(3);
|
||||
|
||||
// Unknown currency defaults to 2
|
||||
expect(getCurrencyMinorUnits('XXX')).toEqual(2);
|
||||
});
|
||||
|
||||
tap.test('Currency Utils - should round values correctly', async () => {
|
||||
// EUR - 2 decimals
|
||||
expect(roundToCurrency(10.234, 'EUR')).toEqual(10.23);
|
||||
expect(roundToCurrency(10.235, 'EUR')).toEqual(10.24); // Half-up
|
||||
expect(roundToCurrency(10.236, 'EUR')).toEqual(10.24);
|
||||
|
||||
// JPY - 0 decimals
|
||||
expect(roundToCurrency(1234.56, 'JPY')).toEqual(1235);
|
||||
expect(roundToCurrency(1234.49, 'JPY')).toEqual(1234);
|
||||
|
||||
// KWD - 3 decimals
|
||||
expect(roundToCurrency(10.2345, 'KWD')).toEqual(10.235); // Half-up
|
||||
expect(roundToCurrency(10.2344, 'KWD')).toEqual(10.234);
|
||||
});
|
||||
|
||||
tap.test('Currency Utils - should use different rounding modes', async () => {
|
||||
const value = 10.235;
|
||||
|
||||
// Half-up (default)
|
||||
expect(roundToCurrency(value, 'EUR', RoundingMode.HALF_UP)).toEqual(10.24);
|
||||
|
||||
// Half-down
|
||||
expect(roundToCurrency(value, 'EUR', RoundingMode.HALF_DOWN)).toEqual(10.23);
|
||||
|
||||
// Half-even (banker's rounding)
|
||||
expect(roundToCurrency(10.235, 'EUR', RoundingMode.HALF_EVEN)).toEqual(10.24); // 23 is odd, round up
|
||||
expect(roundToCurrency(10.245, 'EUR', RoundingMode.HALF_EVEN)).toEqual(10.24); // 24 is even, round down
|
||||
|
||||
// Always up
|
||||
expect(roundToCurrency(10.231, 'EUR', RoundingMode.UP)).toEqual(10.24);
|
||||
|
||||
// Always down (truncate)
|
||||
expect(roundToCurrency(10.239, 'EUR', RoundingMode.DOWN)).toEqual(10.23);
|
||||
});
|
||||
|
||||
tap.test('Currency Utils - should calculate correct tolerance', async () => {
|
||||
// EUR - tolerance is 0.005 (half of 0.01)
|
||||
expect(getCurrencyTolerance('EUR')).toEqual(0.005);
|
||||
|
||||
// JPY - tolerance is 0.5 (half of 1)
|
||||
expect(getCurrencyTolerance('JPY')).toEqual(0.5);
|
||||
|
||||
// KWD - tolerance is 0.0005 (half of 0.001)
|
||||
expect(getCurrencyTolerance('KWD')).toEqual(0.0005);
|
||||
});
|
||||
|
||||
tap.test('Currency Utils - should compare monetary values with tolerance', async () => {
|
||||
// EUR comparisons
|
||||
expect(areMonetaryValuesEqual(10.23, 10.234, 'EUR')).toEqual(true); // Within tolerance
|
||||
expect(areMonetaryValuesEqual(10.23, 10.236, 'EUR')).toEqual(false); // Outside tolerance
|
||||
|
||||
// JPY comparisons
|
||||
expect(areMonetaryValuesEqual(1234, 1234.4, 'JPY')).toEqual(true); // Within tolerance
|
||||
expect(areMonetaryValuesEqual(1234, 1235, 'JPY')).toEqual(false); // Outside tolerance
|
||||
|
||||
// KWD comparisons
|
||||
expect(areMonetaryValuesEqual(10.234, 10.2344, 'KWD')).toEqual(true); // Within tolerance
|
||||
expect(areMonetaryValuesEqual(10.234, 10.235, 'KWD')).toEqual(false); // Outside tolerance
|
||||
});
|
||||
|
||||
tap.test('CurrencyCalculator - should perform EN16931 calculations', async () => {
|
||||
// EUR calculator
|
||||
const eurCalc = new CurrencyCalculator('EUR');
|
||||
|
||||
// Line net calculation
|
||||
const lineNet = eurCalc.calculateLineNet(5, 19.99, 2.50);
|
||||
expect(lineNet).toEqual(97.45); // (5 * 19.99) - 2.50 = 97.45
|
||||
|
||||
// VAT calculation
|
||||
const vat = eurCalc.calculateVAT(100, 19);
|
||||
expect(vat).toEqual(19.00);
|
||||
|
||||
// JPY calculator (no decimals)
|
||||
const jpyCalc = new CurrencyCalculator('JPY');
|
||||
|
||||
const jpyLineNet = jpyCalc.calculateLineNet(3, 1234.56);
|
||||
expect(jpyLineNet).toEqual(3704); // Rounded to no decimals
|
||||
|
||||
const jpyVat = jpyCalc.calculateVAT(10000, 8);
|
||||
expect(jpyVat).toEqual(800);
|
||||
});
|
||||
|
||||
tap.test('CurrencyCalculator - should handle edge cases', async () => {
|
||||
const calc = new CurrencyCalculator('EUR');
|
||||
|
||||
// Rounding at exact midpoint
|
||||
expect(calc.round(10.235)).toEqual(10.24); // Half-up
|
||||
expect(calc.round(10.245)).toEqual(10.25); // Half-up
|
||||
|
||||
// Very small values
|
||||
expect(calc.round(0.001)).toEqual(0.00);
|
||||
expect(calc.round(0.004)).toEqual(0.00);
|
||||
expect(calc.round(0.005)).toEqual(0.01);
|
||||
|
||||
// Negative values
|
||||
expect(calc.round(-10.234)).toEqual(-10.23);
|
||||
expect(calc.round(-10.235)).toEqual(-10.24);
|
||||
});
|
||||
|
||||
tap.start();
|
238
test/test.en16931-validators.ts
Normal file
238
test/test.en16931-validators.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { EInvoice } from '../ts/index.js';
|
||||
import { ValidationLevel } from '../ts/interfaces/common.js';
|
||||
|
||||
// Test EN16931 business rules and code list validators
|
||||
tap.test('EN16931 Validators - should validate business rules with feature flags', async () => {
|
||||
// Create a minimal invoice that violates several EN16931 rules
|
||||
const invoice = new EInvoice();
|
||||
|
||||
// Set some basic fields but leave mandatory ones missing
|
||||
invoice.currency = 'EUR';
|
||||
invoice.date = Date.now();
|
||||
invoice.from = {
|
||||
type: 'company',
|
||||
name: 'Test Seller',
|
||||
address: {
|
||||
streetName: 'Test Street',
|
||||
houseNumber: '1',
|
||||
city: 'Berlin',
|
||||
postalCode: '10115',
|
||||
countryCode: 'DE'
|
||||
}
|
||||
} as any;
|
||||
|
||||
// Missing buyer details and invoice ID (violates BR-02, BR-07)
|
||||
|
||||
// Add an item with calculation issues
|
||||
invoice.items = [{
|
||||
position: 1,
|
||||
name: 'Test Item',
|
||||
unitType: 'C62', // Valid UNECE code
|
||||
unitQuantity: 10,
|
||||
unitNetPrice: 100,
|
||||
vatPercentage: 19
|
||||
}];
|
||||
|
||||
// Test without feature flags (should pass basic validation)
|
||||
const basicResult = await invoice.validate(ValidationLevel.BUSINESS);
|
||||
console.log('Basic validation errors:', basicResult.errors.length);
|
||||
|
||||
// Test with EN16931 business rules feature flag
|
||||
const en16931Result = await invoice.validate(ValidationLevel.BUSINESS, {
|
||||
featureFlags: ['EN16931_BUSINESS_RULES'],
|
||||
checkCalculations: true,
|
||||
checkVAT: true
|
||||
});
|
||||
|
||||
console.log('EN16931 validation errors:', en16931Result.errors.length);
|
||||
|
||||
// Should find missing mandatory fields
|
||||
const mandatoryErrors = en16931Result.errors.filter(e =>
|
||||
e.code && ['BR-01', 'BR-02', 'BR-07'].includes(e.code)
|
||||
);
|
||||
expect(mandatoryErrors.length).toBeGreaterThan(0);
|
||||
|
||||
// Test code list validation
|
||||
const codeListResult = await invoice.validate(ValidationLevel.BUSINESS, {
|
||||
featureFlags: ['CODE_LIST_VALIDATION'],
|
||||
checkCodeLists: true
|
||||
});
|
||||
|
||||
console.log('Code list validation errors:', codeListResult.errors.length);
|
||||
|
||||
// Test invalid currency code
|
||||
invoice.currency = 'XXX' as any; // Invalid currency
|
||||
const currencyResult = await invoice.validate(ValidationLevel.BUSINESS, {
|
||||
featureFlags: ['CODE_LIST_VALIDATION']
|
||||
});
|
||||
|
||||
const currencyErrors = currencyResult.errors.filter(e =>
|
||||
e.code && e.code.includes('BR-CL-03')
|
||||
);
|
||||
expect(currencyErrors.length).toEqual(1);
|
||||
|
||||
// Test with both validators enabled
|
||||
const fullResult = await invoice.validate(ValidationLevel.BUSINESS, {
|
||||
featureFlags: ['EN16931_BUSINESS_RULES', 'CODE_LIST_VALIDATION'],
|
||||
checkCalculations: true,
|
||||
checkVAT: true,
|
||||
checkCodeLists: true,
|
||||
reportOnly: true // Don't fail validation, just report
|
||||
});
|
||||
|
||||
console.log('Full validation with both validators:');
|
||||
console.log('- Total errors:', fullResult.errors.length);
|
||||
console.log('- Valid (report-only mode):', fullResult.valid);
|
||||
|
||||
expect(fullResult.valid).toEqual(true); // Should be true in report-only mode
|
||||
expect(fullResult.errors.length).toBeGreaterThan(0); // Should find issues
|
||||
console.log('Error codes found:', fullResult.errors.map(e => e.code));
|
||||
});
|
||||
|
||||
tap.test('EN16931 Validators - should validate calculations correctly', async () => {
|
||||
const invoice = new EInvoice();
|
||||
|
||||
// Set up a complete invoice with correct mandatory fields
|
||||
invoice.accountingDocId = 'INV-2024-001';
|
||||
invoice.currency = 'EUR';
|
||||
invoice.date = Date.now();
|
||||
invoice.metadata = {
|
||||
customizationId: 'urn:cen.eu:en16931:2017'
|
||||
};
|
||||
|
||||
invoice.from = {
|
||||
type: 'company',
|
||||
name: 'Test Seller GmbH',
|
||||
address: {
|
||||
streetName: 'Hauptstraße',
|
||||
houseNumber: '1',
|
||||
city: 'Berlin',
|
||||
postalCode: '10115',
|
||||
countryCode: 'DE'
|
||||
}
|
||||
} as any;
|
||||
|
||||
invoice.to = {
|
||||
type: 'company',
|
||||
name: 'Test Buyer Ltd',
|
||||
address: {
|
||||
streetName: 'Main Street',
|
||||
houseNumber: '10',
|
||||
city: 'London',
|
||||
postalCode: 'SW1A 1AA',
|
||||
countryCode: 'GB'
|
||||
}
|
||||
} as any;
|
||||
|
||||
// Add items with specific amounts
|
||||
invoice.items = [
|
||||
{
|
||||
position: 1,
|
||||
name: 'Product A',
|
||||
unitType: 'C62',
|
||||
unitQuantity: 5,
|
||||
unitNetPrice: 100.00,
|
||||
vatPercentage: 19
|
||||
},
|
||||
{
|
||||
position: 2,
|
||||
name: 'Product B',
|
||||
unitType: 'C62',
|
||||
unitQuantity: 3,
|
||||
unitNetPrice: 50.00,
|
||||
vatPercentage: 19
|
||||
}
|
||||
];
|
||||
|
||||
// Expected calculations:
|
||||
// Line 1: 5 * 100 = 500
|
||||
// Line 2: 3 * 50 = 150
|
||||
// Total net: 650
|
||||
// VAT (19%): 123.50
|
||||
// Total gross: 773.50
|
||||
|
||||
const result = await invoice.validate(ValidationLevel.BUSINESS, {
|
||||
featureFlags: ['EN16931_BUSINESS_RULES'],
|
||||
checkCalculations: true,
|
||||
tolerance: 0.01
|
||||
});
|
||||
|
||||
// Should not have calculation errors
|
||||
const calcErrors = result.errors.filter(e =>
|
||||
e.code && e.code.startsWith('BR-CO-')
|
||||
);
|
||||
|
||||
console.log('Calculation validation errors:', calcErrors);
|
||||
expect(calcErrors.length).toEqual(0);
|
||||
|
||||
// Verify computed totals
|
||||
expect(invoice.totalNet).toEqual(650);
|
||||
expect(invoice.totalVat).toEqual(123.50);
|
||||
expect(invoice.totalGross).toEqual(773.50);
|
||||
});
|
||||
|
||||
tap.test('EN16931 Validators - should validate VAT rules correctly', async () => {
|
||||
const invoice = new EInvoice();
|
||||
|
||||
// Set up mandatory fields
|
||||
invoice.accountingDocId = 'INV-2024-002';
|
||||
invoice.currency = 'EUR';
|
||||
invoice.date = Date.now();
|
||||
invoice.metadata = {
|
||||
customizationId: 'urn:cen.eu:en16931:2017'
|
||||
};
|
||||
|
||||
invoice.from = {
|
||||
type: 'company',
|
||||
name: 'Seller',
|
||||
address: { countryCode: 'DE' }
|
||||
} as any;
|
||||
|
||||
invoice.to = {
|
||||
type: 'company',
|
||||
name: 'Buyer',
|
||||
address: { countryCode: 'FR' }
|
||||
} as any;
|
||||
|
||||
// Add mixed VAT rate items
|
||||
invoice.items = [
|
||||
{
|
||||
position: 1,
|
||||
name: 'Standard rated item',
|
||||
unitType: 'C62',
|
||||
unitQuantity: 1,
|
||||
unitNetPrice: 100,
|
||||
vatPercentage: 19 // Standard rate
|
||||
},
|
||||
{
|
||||
position: 2,
|
||||
name: 'Zero rated item',
|
||||
unitType: 'C62',
|
||||
unitQuantity: 1,
|
||||
unitNetPrice: 100,
|
||||
vatPercentage: 0 // Zero rate
|
||||
}
|
||||
];
|
||||
|
||||
const result = await invoice.validate(ValidationLevel.BUSINESS, {
|
||||
featureFlags: ['EN16931_BUSINESS_RULES'],
|
||||
checkVAT: true
|
||||
});
|
||||
|
||||
// Check for VAT breakdown requirements
|
||||
const vatErrors = result.errors.filter(e =>
|
||||
e.code && (e.code.startsWith('BR-S-') || e.code.startsWith('BR-Z-'))
|
||||
);
|
||||
|
||||
console.log('VAT validation results:');
|
||||
console.log('- VAT errors found:', vatErrors.length);
|
||||
console.log('- Tax breakdown:', invoice.taxBreakdown);
|
||||
|
||||
// Should have proper tax breakdown
|
||||
expect(invoice.taxBreakdown.length).toEqual(2);
|
||||
expect(invoice.taxBreakdown.find(t => t.taxPercent === 19)).toBeTruthy();
|
||||
expect(invoice.taxBreakdown.find(t => t.taxPercent === 0)).toBeTruthy();
|
||||
});
|
||||
|
||||
export default tap.start();
|
163
test/test.schematron-validator.ts
Normal file
163
test/test.schematron-validator.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { SchematronValidator, HybridValidator } from '../ts/formats/validation/schematron.validator.js';
|
||||
import { SchematronDownloader } from '../ts/formats/validation/schematron.downloader.js';
|
||||
import { SchematronWorkerPool } from '../ts/formats/validation/schematron.worker.js';
|
||||
|
||||
tap.test('Schematron Infrastructure - should initialize correctly', async () => {
|
||||
const validator = new SchematronValidator();
|
||||
expect(validator).toBeInstanceOf(SchematronValidator);
|
||||
expect(validator.hasRules()).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('Schematron Infrastructure - should load Schematron rules', async () => {
|
||||
const validator = new SchematronValidator();
|
||||
|
||||
// Load a simple test Schematron
|
||||
const testSchematron = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<sch:schema xmlns:sch="http://purl.oclc.org/dsdl/schematron">
|
||||
<sch:ns prefix="ubl" uri="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"/>
|
||||
|
||||
<sch:pattern id="test-pattern">
|
||||
<sch:rule context="//ubl:Invoice">
|
||||
<sch:assert test="ubl:ID" id="TEST-01">
|
||||
Invoice must have an ID
|
||||
</sch:assert>
|
||||
</sch:rule>
|
||||
</sch:pattern>
|
||||
</sch:schema>`;
|
||||
|
||||
await validator.loadSchematron(testSchematron, false);
|
||||
expect(validator.hasRules()).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('Schematron Infrastructure - should detect phases', async () => {
|
||||
const validator = new SchematronValidator();
|
||||
|
||||
const schematronWithPhases = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<sch:schema xmlns:sch="http://purl.oclc.org/dsdl/schematron">
|
||||
<sch:phase id="basic">
|
||||
<sch:active pattern="basic-rules"/>
|
||||
</sch:phase>
|
||||
<sch:phase id="extended">
|
||||
<sch:active pattern="basic-rules"/>
|
||||
<sch:active pattern="extended-rules"/>
|
||||
</sch:phase>
|
||||
|
||||
<sch:pattern id="basic-rules">
|
||||
<sch:rule context="//Invoice">
|
||||
<sch:assert test="ID">Invoice must have ID</sch:assert>
|
||||
</sch:rule>
|
||||
</sch:pattern>
|
||||
</sch:schema>`;
|
||||
|
||||
await validator.loadSchematron(schematronWithPhases, false);
|
||||
const phases = await validator.getPhases();
|
||||
|
||||
expect(phases).toContain('basic');
|
||||
expect(phases).toContain('extended');
|
||||
});
|
||||
|
||||
tap.test('Schematron Downloader - should initialize', async () => {
|
||||
const downloader = new SchematronDownloader('.nogit/schematron-test');
|
||||
await downloader.initialize();
|
||||
|
||||
// Check that sources are defined
|
||||
expect(downloader).toBeInstanceOf(SchematronDownloader);
|
||||
});
|
||||
|
||||
tap.test('Schematron Downloader - should list available sources', async () => {
|
||||
const { SCHEMATRON_SOURCES } = await import('../ts/formats/validation/schematron.downloader.js');
|
||||
|
||||
// Check EN16931 sources
|
||||
expect(SCHEMATRON_SOURCES.EN16931).toBeDefined();
|
||||
expect(SCHEMATRON_SOURCES.EN16931.length).toBeGreaterThan(0);
|
||||
|
||||
const en16931Ubl = SCHEMATRON_SOURCES.EN16931.find(s => s.format === 'UBL');
|
||||
expect(en16931Ubl).toBeDefined();
|
||||
expect(en16931Ubl?.name).toEqual('EN16931-UBL');
|
||||
|
||||
// Check PEPPOL sources
|
||||
expect(SCHEMATRON_SOURCES.PEPPOL).toBeDefined();
|
||||
expect(SCHEMATRON_SOURCES.PEPPOL.length).toBeGreaterThan(0);
|
||||
|
||||
// Check XRechnung sources
|
||||
expect(SCHEMATRON_SOURCES.XRECHNUNG).toBeDefined();
|
||||
expect(SCHEMATRON_SOURCES.XRECHNUNG.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('Hybrid Validator - should combine validators', async () => {
|
||||
const schematronValidator = new SchematronValidator();
|
||||
const hybrid = new HybridValidator(schematronValidator);
|
||||
|
||||
// Add a mock TypeScript validator
|
||||
const mockTSValidator = {
|
||||
validate: (xml: string) => [{
|
||||
ruleId: 'TS-TEST-01',
|
||||
severity: 'error' as const,
|
||||
message: 'Test error from TS validator',
|
||||
btReference: undefined,
|
||||
bgReference: undefined
|
||||
}]
|
||||
};
|
||||
|
||||
hybrid.addTSValidator(mockTSValidator);
|
||||
|
||||
// Test validation (will only run TS validator since no Schematron loaded)
|
||||
const results = await hybrid.validate('<Invoice/>');
|
||||
expect(results.length).toEqual(1);
|
||||
expect(results[0].ruleId).toEqual('TS-TEST-01');
|
||||
});
|
||||
|
||||
tap.test('Schematron Worker Pool - should initialize', async () => {
|
||||
const pool = new SchematronWorkerPool(2);
|
||||
|
||||
// Test pool stats
|
||||
const stats = pool.getStats();
|
||||
expect(stats.totalWorkers).toEqual(0); // Not initialized yet
|
||||
expect(stats.queuedTasks).toEqual(0);
|
||||
|
||||
// Note: Full worker pool test would require actual worker thread setup
|
||||
// which may not work in all test environments
|
||||
});
|
||||
|
||||
tap.test('Schematron Validator - SVRL parsing', async () => {
|
||||
const validator = new SchematronValidator();
|
||||
|
||||
// Test SVRL output parsing
|
||||
const testSVRL = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svrl:schematron-output xmlns:svrl="http://purl.oclc.org/dsdl/svrl">
|
||||
<svrl:active-pattern document="test.xml"/>
|
||||
|
||||
<svrl:failed-assert test="count(ID) = 1"
|
||||
location="/Invoice"
|
||||
id="BR-01"
|
||||
flag="fatal">
|
||||
<svrl:text>[BR-01] Invoice must have exactly one ID</svrl:text>
|
||||
</svrl:failed-assert>
|
||||
|
||||
<svrl:successful-report test="Currency = 'EUR'"
|
||||
location="/Invoice"
|
||||
id="INFO-01"
|
||||
flag="information">
|
||||
<svrl:text>Currency is EUR</svrl:text>
|
||||
</svrl:successful-report>
|
||||
</svrl:schematron-output>`;
|
||||
|
||||
// This would test the SVRL parsing logic
|
||||
// The actual implementation would parse this and return ValidationResult[]
|
||||
expect(testSVRL).toContain('failed-assert');
|
||||
expect(testSVRL).toContain('BR-01');
|
||||
});
|
||||
|
||||
tap.test('Schematron Integration - should handle missing files gracefully', async () => {
|
||||
const validator = new SchematronValidator();
|
||||
|
||||
try {
|
||||
await validator.loadSchematron('non-existent-file.sch', true);
|
||||
expect(true).toBeFalse(); // Should not reach here
|
||||
} catch (error) {
|
||||
expect(error).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
tap.start();
|
Reference in New Issue
Block a user