feat: Implement PEPPOL and XRechnung validators for compliance with e-invoice specifications
- Added PeppolValidator class to validate PEPPOL BIS 3.0 invoices, including checks for endpoint IDs, document type IDs, process IDs, party identification, and business rules. - Implemented validation for GLN check digits, document types, and transport protocols specific to PEPPOL. - Added XRechnungValidator class to validate XRechnung 3.0 invoices, focusing on German-specific requirements such as Leitweg-ID, payment details, seller contact, and tax registration. - Included validation for IBAN and BIC formats, ensuring compliance with SEPA regulations. - Established methods for checking B2G invoice indicators and validating mandatory fields for both validators.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle/index.js';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
|
184
test/test.decimal-currency-calculator.ts
Normal file
184
test/test.decimal-currency-calculator.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DecimalCurrencyCalculator } from '../ts/formats/utils/currency.calculator.decimal.js';
|
||||
import { Decimal } from '../ts/formats/utils/decimal.js';
|
||||
|
||||
tap.test('DecimalCurrencyCalculator - EUR calculations', async () => {
|
||||
const calculator = new DecimalCurrencyCalculator('EUR');
|
||||
|
||||
// Line calculation
|
||||
const lineNet = calculator.calculateLineNet('3', '33.333', '0');
|
||||
expect(lineNet.toString()).toEqual('100'); // calculateLineNet rounds the result
|
||||
|
||||
// VAT calculation
|
||||
const vat = calculator.calculateVAT('100', '19');
|
||||
expect(vat.toString()).toEqual('19');
|
||||
|
||||
// Gross amount
|
||||
const gross = calculator.calculateGrossAmount('100', '19');
|
||||
expect(gross.toString()).toEqual('119');
|
||||
});
|
||||
|
||||
tap.test('DecimalCurrencyCalculator - JPY calculations (no decimals)', async () => {
|
||||
const calculator = new DecimalCurrencyCalculator('JPY');
|
||||
|
||||
// Should round to 0 decimal places
|
||||
const amount = calculator.round('1234.56');
|
||||
expect(amount.toString()).toEqual('1235');
|
||||
|
||||
// VAT calculation
|
||||
const vat = calculator.calculateVAT('1000', '10');
|
||||
expect(vat.toString()).toEqual('100');
|
||||
});
|
||||
|
||||
tap.test('DecimalCurrencyCalculator - KWD calculations (3 decimals)', async () => {
|
||||
const calculator = new DecimalCurrencyCalculator('KWD');
|
||||
|
||||
// Should maintain 3 decimal places
|
||||
const amount = calculator.round('123.4567');
|
||||
expect(amount.toString()).toEqual('123.457');
|
||||
|
||||
// VAT calculation
|
||||
const vat = calculator.calculateVAT('100.000', '5');
|
||||
expect(vat.toString()).toEqual('5');
|
||||
});
|
||||
|
||||
tap.test('DecimalCurrencyCalculator - sum line items', async () => {
|
||||
const calculator = new DecimalCurrencyCalculator('EUR');
|
||||
|
||||
const items = [
|
||||
{ quantity: '2', unitPrice: '50.00', discount: '5.00' },
|
||||
{ quantity: '3', unitPrice: '33.33', discount: '0' },
|
||||
{ quantity: '1', unitPrice: '100.00', discount: '10.00' }
|
||||
];
|
||||
|
||||
const total = calculator.sumLineItems(items);
|
||||
expect(total.toString()).toEqual('284.99');
|
||||
});
|
||||
|
||||
tap.test('DecimalCurrencyCalculator - VAT breakdown', async () => {
|
||||
const calculator = new DecimalCurrencyCalculator('EUR');
|
||||
|
||||
const items = [
|
||||
{ netAmount: '100.00', vatRate: '19' },
|
||||
{ netAmount: '50.00', vatRate: '19' },
|
||||
{ netAmount: '200.00', vatRate: '7' }
|
||||
];
|
||||
|
||||
const breakdown = calculator.calculateVATBreakdown(items);
|
||||
|
||||
expect(breakdown).toHaveLength(2);
|
||||
|
||||
const vat19 = breakdown.find(b => b.rate.toString() === '19');
|
||||
expect(vat19?.baseAmount.toString()).toEqual('150');
|
||||
expect(vat19?.vatAmount.toString()).toEqual('28.5');
|
||||
|
||||
const vat7 = breakdown.find(b => b.rate.toString() === '7');
|
||||
expect(vat7?.baseAmount.toString()).toEqual('200');
|
||||
expect(vat7?.vatAmount.toString()).toEqual('14');
|
||||
});
|
||||
|
||||
tap.test('DecimalCurrencyCalculator - distribute amount', async () => {
|
||||
const calculator = new DecimalCurrencyCalculator('EUR');
|
||||
|
||||
// Distribute 100 EUR across three items
|
||||
const items = [
|
||||
{ value: '30' }, // 30%
|
||||
{ value: '50' }, // 50%
|
||||
{ value: '20' } // 20%
|
||||
];
|
||||
|
||||
const distributed = calculator.distributeAmount('100', items);
|
||||
|
||||
expect(distributed[0].toString()).toEqual('30');
|
||||
expect(distributed[1].toString()).toEqual('50');
|
||||
expect(distributed[2].toString()).toEqual('20');
|
||||
|
||||
// Sum should equal total
|
||||
const sum = Decimal.sum(distributed);
|
||||
expect(sum.toString()).toEqual('100');
|
||||
});
|
||||
|
||||
tap.test('DecimalCurrencyCalculator - compound adjustments', async () => {
|
||||
const calculator = new DecimalCurrencyCalculator('EUR');
|
||||
|
||||
const adjustments = [
|
||||
{ type: 'allowance' as const, value: '10', isPercentage: true }, // -10%
|
||||
{ type: 'charge' as const, value: '5', isPercentage: false }, // +5 EUR
|
||||
{ type: 'allowance' as const, value: '2', isPercentage: false } // -2 EUR
|
||||
];
|
||||
|
||||
const result = calculator.calculateCompoundAmount('100', adjustments);
|
||||
// 100 - 10% = 90, + 5 = 95, - 2 = 93
|
||||
expect(result.toString()).toEqual('93');
|
||||
});
|
||||
|
||||
tap.test('DecimalCurrencyCalculator - validation', async () => {
|
||||
const calculator = new DecimalCurrencyCalculator('EUR');
|
||||
|
||||
// Valid calculation
|
||||
const result1 = calculator.validateCalculation('119.00', '119.00', 'BR-CO-15');
|
||||
expect(result1.valid).toBeTrue();
|
||||
expect(result1.expected).toEqual('119.00');
|
||||
expect(result1.calculated).toEqual('119.00');
|
||||
|
||||
// Invalid calculation
|
||||
const result2 = calculator.validateCalculation('119.00', '118.99', 'BR-CO-15');
|
||||
expect(result2.valid).toBeFalse();
|
||||
expect(result2.difference).toEqual('0.01');
|
||||
});
|
||||
|
||||
tap.test('DecimalCurrencyCalculator - different rounding modes', async () => {
|
||||
// HALF_DOWN for specific requirements
|
||||
const calculator = new DecimalCurrencyCalculator('EUR', 'HALF_DOWN');
|
||||
|
||||
const amount1 = calculator.round('10.125'); // Should round down
|
||||
expect(amount1.toString()).toEqual('10.12');
|
||||
|
||||
const amount2 = calculator.round('10.135'); // Should round down with HALF_DOWN
|
||||
expect(amount2.toString()).toEqual('10.13');
|
||||
|
||||
// HALF_EVEN (Banker's rounding) for statistical accuracy
|
||||
const bankerCalc = new DecimalCurrencyCalculator('EUR', 'HALF_EVEN');
|
||||
|
||||
const amount3 = bankerCalc.round('10.125'); // Round to even (down)
|
||||
expect(amount3.toString()).toEqual('10.12');
|
||||
|
||||
const amount4 = bankerCalc.round('10.135'); // Round to even (up)
|
||||
expect(amount4.toString()).toEqual('10.14');
|
||||
});
|
||||
|
||||
tap.test('DecimalCurrencyCalculator - real invoice scenario', async () => {
|
||||
const calculator = new DecimalCurrencyCalculator('EUR');
|
||||
|
||||
// Invoice lines
|
||||
const lines = [
|
||||
{ quantity: '2.5', unitPrice: '45.60', discount: '5.00' },
|
||||
{ quantity: '10', unitPrice: '12.34', discount: '0' },
|
||||
{ quantity: '1', unitPrice: '250.00', discount: '25.00' }
|
||||
];
|
||||
|
||||
// Calculate line totals
|
||||
const lineTotal = calculator.sumLineItems(lines);
|
||||
expect(lineTotal.toString()).toEqual('457.4');
|
||||
|
||||
// Apply document-level allowance (2%)
|
||||
const allowance = calculator.calculatePaymentDiscount(lineTotal, '2');
|
||||
expect(allowance.toString()).toEqual('9.15');
|
||||
|
||||
const netAfterAllowance = lineTotal.subtract(allowance);
|
||||
expect(calculator.round(netAfterAllowance).toString()).toEqual('448.25');
|
||||
|
||||
// Calculate VAT at 19%
|
||||
const vat = calculator.calculateVAT(netAfterAllowance, '19');
|
||||
expect(vat.toString()).toEqual('85.17');
|
||||
|
||||
// Total with VAT
|
||||
const total = calculator.calculateGrossAmount(netAfterAllowance, vat);
|
||||
expect(total.toString()).toEqual('533.42');
|
||||
|
||||
// Format for display
|
||||
const formatted = calculator.formatAmount(total);
|
||||
expect(formatted).toEqual('533.42 EUR');
|
||||
});
|
||||
|
||||
export default tap.start();
|
257
test/test.decimal.ts
Normal file
257
test/test.decimal.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { Decimal, decimal, RoundingMode } from '../ts/formats/utils/decimal.js';
|
||||
|
||||
tap.test('Decimal - basic construction', async () => {
|
||||
// From string
|
||||
const d1 = new Decimal('123.456');
|
||||
expect(d1.toString()).toEqual('123.456');
|
||||
|
||||
// From number
|
||||
const d2 = new Decimal(123.456);
|
||||
expect(d2.toString()).toEqual('123.456');
|
||||
|
||||
// From bigint
|
||||
const d3 = new Decimal(123n);
|
||||
expect(d3.toString()).toEqual('123');
|
||||
|
||||
// From another Decimal
|
||||
const d4 = new Decimal(d1);
|
||||
expect(d4.toString()).toEqual('123.456');
|
||||
|
||||
// Negative values
|
||||
const d5 = new Decimal('-123.456');
|
||||
expect(d5.toString()).toEqual('-123.456');
|
||||
});
|
||||
|
||||
tap.test('Decimal - arithmetic operations', async () => {
|
||||
const a = new Decimal('10.50');
|
||||
const b = new Decimal('3.25');
|
||||
|
||||
// Addition
|
||||
expect(a.add(b).toString()).toEqual('13.75');
|
||||
|
||||
// Subtraction
|
||||
expect(a.subtract(b).toString()).toEqual('7.25');
|
||||
|
||||
// Multiplication
|
||||
expect(a.multiply(b).toString()).toEqual('34.125');
|
||||
|
||||
// Division
|
||||
expect(a.divide(b).toString()).toEqual('3.2307692307');
|
||||
|
||||
// Percentage
|
||||
const amount = new Decimal('100');
|
||||
const rate = new Decimal('19');
|
||||
expect(amount.percentage(rate).toString()).toEqual('19');
|
||||
});
|
||||
|
||||
tap.test('Decimal - rounding modes', async () => {
|
||||
// HALF_UP (default)
|
||||
expect(new Decimal('2.5').round(0, 'HALF_UP').toString()).toEqual('3');
|
||||
expect(new Decimal('2.4').round(0, 'HALF_UP').toString()).toEqual('2');
|
||||
expect(new Decimal('-2.5').round(0, 'HALF_UP').toString()).toEqual('-3');
|
||||
|
||||
// HALF_DOWN
|
||||
expect(new Decimal('2.5').round(0, 'HALF_DOWN').toString()).toEqual('2');
|
||||
expect(new Decimal('2.6').round(0, 'HALF_DOWN').toString()).toEqual('3');
|
||||
expect(new Decimal('-2.5').round(0, 'HALF_DOWN').toString()).toEqual('-2');
|
||||
|
||||
// HALF_EVEN (Banker's rounding)
|
||||
expect(new Decimal('2.5').round(0, 'HALF_EVEN').toString()).toEqual('2');
|
||||
expect(new Decimal('3.5').round(0, 'HALF_EVEN').toString()).toEqual('4');
|
||||
expect(new Decimal('2.4').round(0, 'HALF_EVEN').toString()).toEqual('2');
|
||||
expect(new Decimal('2.6').round(0, 'HALF_EVEN').toString()).toEqual('3');
|
||||
|
||||
// UP (away from zero)
|
||||
expect(new Decimal('2.1').round(0, 'UP').toString()).toEqual('3');
|
||||
expect(new Decimal('-2.1').round(0, 'UP').toString()).toEqual('-3');
|
||||
|
||||
// DOWN (toward zero)
|
||||
expect(new Decimal('2.9').round(0, 'DOWN').toString()).toEqual('2');
|
||||
expect(new Decimal('-2.9').round(0, 'DOWN').toString()).toEqual('-2');
|
||||
|
||||
// CEILING (toward positive infinity)
|
||||
expect(new Decimal('2.1').round(0, 'CEILING').toString()).toEqual('3');
|
||||
expect(new Decimal('-2.9').round(0, 'CEILING').toString()).toEqual('-2');
|
||||
|
||||
// FLOOR (toward negative infinity)
|
||||
expect(new Decimal('2.9').round(0, 'FLOOR').toString()).toEqual('2');
|
||||
expect(new Decimal('-2.1').round(0, 'FLOOR').toString()).toEqual('-3');
|
||||
});
|
||||
|
||||
tap.test('Decimal - EN16931 calculation scenarios', async () => {
|
||||
// Line item calculation
|
||||
const quantity = new Decimal('3');
|
||||
const unitPrice = new Decimal('33.333333');
|
||||
const lineTotal = quantity.multiply(unitPrice);
|
||||
expect(lineTotal.round(2).toString()).toEqual('100');
|
||||
|
||||
// VAT calculation
|
||||
const netAmount = new Decimal('100');
|
||||
const vatRate = new Decimal('19');
|
||||
const vatAmount = netAmount.percentage(vatRate);
|
||||
expect(vatAmount.toString()).toEqual('19');
|
||||
|
||||
// Total with VAT
|
||||
const grossAmount = netAmount.add(vatAmount);
|
||||
expect(grossAmount.toString()).toEqual('119');
|
||||
|
||||
// Complex calculation with allowances
|
||||
const lineExtension = new Decimal('150.00');
|
||||
const allowance = new Decimal('10.00');
|
||||
const charge = new Decimal('5.00');
|
||||
const taxExclusive = lineExtension.subtract(allowance).add(charge);
|
||||
expect(taxExclusive.toString()).toEqual('145');
|
||||
|
||||
const vat = taxExclusive.percentage(new Decimal('19'));
|
||||
expect(vat.round(2).toString()).toEqual('27.55');
|
||||
|
||||
const total = taxExclusive.add(vat);
|
||||
expect(total.round(2).toString()).toEqual('172.55');
|
||||
});
|
||||
|
||||
tap.test('Decimal - comparisons', async () => {
|
||||
const a = new Decimal('10.50');
|
||||
const b = new Decimal('10.50');
|
||||
const c = new Decimal('10.51');
|
||||
|
||||
// Equality
|
||||
expect(a.equals(b)).toBeTrue();
|
||||
expect(a.equals(c)).toBeFalse();
|
||||
|
||||
// With tolerance
|
||||
expect(a.equals(c, '0.01')).toBeTrue();
|
||||
expect(a.equals(c, '0.005')).toBeFalse();
|
||||
|
||||
// Comparisons
|
||||
expect(a.lessThan(c)).toBeTrue();
|
||||
expect(c.greaterThan(a)).toBeTrue();
|
||||
expect(a.lessThanOrEqual(b)).toBeTrue();
|
||||
expect(a.greaterThanOrEqual(b)).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('Decimal - edge cases', async () => {
|
||||
// Very small numbers
|
||||
const tiny = new Decimal('0.0000000001');
|
||||
expect(tiny.multiply(new Decimal('1000000000')).toString()).toEqual('0.1');
|
||||
|
||||
// Very large numbers
|
||||
const huge = new Decimal('999999999999999999');
|
||||
expect(huge.add(new Decimal('1')).toString()).toEqual('1000000000000000000');
|
||||
|
||||
// Division by zero
|
||||
const zero = new Decimal('0');
|
||||
const one = new Decimal('1');
|
||||
let errorThrown = false;
|
||||
try {
|
||||
one.divide(zero);
|
||||
} catch (e) {
|
||||
errorThrown = true;
|
||||
expect(e.message).toEqual('Division by zero');
|
||||
}
|
||||
expect(errorThrown).toBeTrue();
|
||||
|
||||
// Zero operations
|
||||
expect(zero.add(one).toString()).toEqual('1');
|
||||
expect(zero.multiply(one).toString()).toEqual('0');
|
||||
expect(zero.isZero()).toBeTrue();
|
||||
expect(one.isZero()).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('Decimal - currency calculations with different minor units', async () => {
|
||||
// EUR (2 decimal places)
|
||||
const eurAmount = new Decimal('100.00');
|
||||
const eurVat = eurAmount.percentage(new Decimal('19'));
|
||||
expect(eurVat.round(2).toString()).toEqual('19');
|
||||
|
||||
// JPY (0 decimal places)
|
||||
const jpyAmount = new Decimal('1000');
|
||||
const jpyTax = jpyAmount.percentage(new Decimal('10'));
|
||||
expect(jpyTax.round(0).toString()).toEqual('100');
|
||||
|
||||
// KWD (3 decimal places)
|
||||
const kwdAmount = new Decimal('100.000');
|
||||
const kwdTax = kwdAmount.percentage(new Decimal('5'));
|
||||
expect(kwdTax.round(3).toString()).toEqual('5');
|
||||
|
||||
// BTC (8 decimal places for satoshis)
|
||||
const btcAmount = new Decimal('0.00100000');
|
||||
const btcFee = btcAmount.percentage(new Decimal('0.1'));
|
||||
expect(btcFee.round(8).toString()).toEqual('0.000001');
|
||||
});
|
||||
|
||||
tap.test('Decimal - static methods', async () => {
|
||||
// Sum
|
||||
const values = ['10.50', '20.25', '30.75'];
|
||||
const sum = Decimal.sum(values);
|
||||
expect(sum.toString()).toEqual('61.5');
|
||||
|
||||
// Min
|
||||
const min = Decimal.min('10.50', '20.25', '5.75');
|
||||
expect(min.toString()).toEqual('5.75');
|
||||
|
||||
// Max
|
||||
const max = Decimal.max('10.50', '20.25', '5.75');
|
||||
expect(max.toString()).toEqual('20.25');
|
||||
|
||||
// From percentage
|
||||
const rate = Decimal.fromPercentage('19%');
|
||||
expect(rate.toString()).toEqual('0.19');
|
||||
});
|
||||
|
||||
tap.test('Decimal - formatting', async () => {
|
||||
const value = new Decimal('1234.567890');
|
||||
|
||||
// Fixed decimal places
|
||||
expect(value.toFixed(2)).toEqual('1234.57');
|
||||
expect(value.toFixed(0)).toEqual('1235');
|
||||
expect(value.toFixed(4)).toEqual('1234.5679');
|
||||
|
||||
// toString with decimal places
|
||||
expect(value.toString(2)).toEqual('1234.56');
|
||||
expect(value.toString(6)).toEqual('1234.567890');
|
||||
|
||||
// Automatic trailing zero removal
|
||||
const rounded = new Decimal('100.00');
|
||||
expect(rounded.toString()).toEqual('100');
|
||||
expect(rounded.toFixed(2)).toEqual('100.00');
|
||||
});
|
||||
|
||||
tap.test('Decimal - real-world invoice calculation', async () => {
|
||||
// Invoice with multiple lines and VAT rates
|
||||
const lines = [
|
||||
{ quantity: '2', unitPrice: '50.00', vatRate: '19' },
|
||||
{ quantity: '3', unitPrice: '33.33', vatRate: '19' },
|
||||
{ quantity: '1', unitPrice: '100.00', vatRate: '7' }
|
||||
];
|
||||
|
||||
let totalNet = Decimal.ZERO;
|
||||
let totalVat19 = Decimal.ZERO;
|
||||
let totalVat7 = Decimal.ZERO;
|
||||
|
||||
for (const line of lines) {
|
||||
const quantity = new Decimal(line.quantity);
|
||||
const unitPrice = new Decimal(line.unitPrice);
|
||||
const lineNet = quantity.multiply(unitPrice);
|
||||
totalNet = totalNet.add(lineNet);
|
||||
|
||||
const vatAmount = lineNet.percentage(new Decimal(line.vatRate));
|
||||
if (line.vatRate === '19') {
|
||||
totalVat19 = totalVat19.add(vatAmount);
|
||||
} else {
|
||||
totalVat7 = totalVat7.add(vatAmount);
|
||||
}
|
||||
}
|
||||
|
||||
expect(totalNet.round(2).toString()).toEqual('299.99');
|
||||
expect(totalVat19.round(2).toString()).toEqual('38');
|
||||
expect(totalVat7.round(2).toString()).toEqual('7');
|
||||
|
||||
const totalVat = totalVat19.add(totalVat7);
|
||||
const totalGross = totalNet.add(totalVat);
|
||||
|
||||
expect(totalVat.round(2).toString()).toEqual('45');
|
||||
expect(totalGross.round(2).toString()).toEqual('344.99');
|
||||
});
|
||||
|
||||
export default tap.start();
|
@@ -196,4 +196,4 @@ tap.test('EInvoice should export XML correctly', async () => {
|
||||
});
|
||||
|
||||
// Run the tests
|
||||
tap.start();
|
||||
export default tap.start();
|
||||
|
453
test/test.facturx-validator.ts
Normal file
453
test/test.facturx-validator.ts
Normal file
@@ -0,0 +1,453 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { FacturXValidator, FacturXProfile } from '../ts/formats/validation/facturx.validator.js';
|
||||
import type { EInvoice } from '../ts/einvoice.js';
|
||||
|
||||
tap.test('Factur-X Validator - basic instantiation', async () => {
|
||||
const validator = FacturXValidator.create();
|
||||
expect(validator).toBeInstanceOf(FacturXValidator);
|
||||
|
||||
// Singleton pattern
|
||||
const validator2 = FacturXValidator.create();
|
||||
expect(validator2).toEqual(validator);
|
||||
});
|
||||
|
||||
tap.test('Factur-X Validator - profile detection', async () => {
|
||||
const validator = FacturXValidator.create();
|
||||
|
||||
// MINIMUM profile
|
||||
const minInvoice: Partial<EInvoice> = {
|
||||
metadata: {
|
||||
profileId: 'urn:facturx:minimum:2017'
|
||||
}
|
||||
};
|
||||
expect(validator.detectProfile(minInvoice as EInvoice)).toEqual(FacturXProfile.MINIMUM);
|
||||
|
||||
// BASIC profile
|
||||
const basicInvoice: Partial<EInvoice> = {
|
||||
metadata: {
|
||||
profileId: 'urn:facturx:basic:2017'
|
||||
}
|
||||
};
|
||||
expect(validator.detectProfile(basicInvoice as EInvoice)).toEqual(FacturXProfile.BASIC);
|
||||
|
||||
// EN16931 profile (Comfort)
|
||||
const en16931Invoice: Partial<EInvoice> = {
|
||||
metadata: {
|
||||
profileId: 'urn:facturx:comfort:2017'
|
||||
}
|
||||
};
|
||||
expect(validator.detectProfile(en16931Invoice as EInvoice)).toEqual(FacturXProfile.EN16931);
|
||||
|
||||
// EXTENDED profile
|
||||
const extendedInvoice: Partial<EInvoice> = {
|
||||
metadata: {
|
||||
profileId: 'urn:facturx:extended:2017'
|
||||
}
|
||||
};
|
||||
expect(validator.detectProfile(extendedInvoice as EInvoice)).toEqual(FacturXProfile.EXTENDED);
|
||||
|
||||
// Non-Factur-X invoice
|
||||
const otherInvoice: Partial<EInvoice> = {
|
||||
metadata: {
|
||||
profileId: 'urn:cen.eu:en16931:2017'
|
||||
}
|
||||
};
|
||||
expect(validator.detectProfile(otherInvoice as EInvoice)).toEqual(null);
|
||||
});
|
||||
|
||||
tap.test('Factur-X Validator - MINIMUM profile validation', async () => {
|
||||
const validator = FacturXValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
metadata: {
|
||||
profileId: 'urn:facturx:minimum:2017'
|
||||
},
|
||||
accountingDocId: 'INV-2025-001',
|
||||
issueDate: new Date('2025-01-11'),
|
||||
accountingDocType: 'invoice',
|
||||
currency: 'EUR',
|
||||
from: {
|
||||
type: 'company',
|
||||
name: 'Test Seller',
|
||||
vatNumber: 'DE123456789'
|
||||
},
|
||||
to: {
|
||||
type: 'company',
|
||||
name: 'Test Buyer'
|
||||
},
|
||||
totalInvoiceAmount: 119.00,
|
||||
totalNetAmount: 100.00,
|
||||
totalVatAmount: 19.00
|
||||
};
|
||||
|
||||
const results = validator.validateFacturX(invoice as EInvoice, FacturXProfile.MINIMUM);
|
||||
const errors = results.filter(r => r.severity === 'error');
|
||||
|
||||
console.log('MINIMUM profile validation errors:', errors);
|
||||
expect(errors.length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('Factur-X Validator - MINIMUM profile missing fields', async () => {
|
||||
const validator = FacturXValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
metadata: {
|
||||
profileId: 'urn:facturx:minimum:2017'
|
||||
},
|
||||
accountingDocId: 'INV-2025-001',
|
||||
issueDate: new Date('2025-01-11'),
|
||||
// Missing required fields for MINIMUM
|
||||
};
|
||||
|
||||
const results = validator.validateFacturX(invoice as EInvoice, FacturXProfile.MINIMUM);
|
||||
const errors = results.filter(r => r.severity === 'error');
|
||||
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.some(e => e.field === 'currency')).toBeTrue();
|
||||
expect(errors.some(e => e.field === 'from.name')).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('Factur-X Validator - BASIC profile validation', async () => {
|
||||
const validator = FacturXValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
metadata: {
|
||||
profileId: 'urn:facturx:basic:2017'
|
||||
},
|
||||
accountingDocId: 'INV-2025-001',
|
||||
issueDate: new Date('2025-01-11'),
|
||||
accountingDocType: 'invoice',
|
||||
currency: 'EUR',
|
||||
dueDate: new Date('2025-02-11'),
|
||||
from: {
|
||||
type: 'company',
|
||||
name: 'Test Seller',
|
||||
vatNumber: 'DE123456789',
|
||||
address: 'Test Street 1',
|
||||
country: 'DE'
|
||||
},
|
||||
to: {
|
||||
type: 'company',
|
||||
name: 'Test Buyer',
|
||||
address: 'Buyer Street 1',
|
||||
country: 'FR'
|
||||
},
|
||||
items: [
|
||||
{
|
||||
position: 1,
|
||||
name: 'Test Product',
|
||||
unitQuantity: 1,
|
||||
unitNetPrice: 100.00,
|
||||
unitType: 'C62',
|
||||
vatPercentage: 19,
|
||||
articleNumber: 'ART-001'
|
||||
}
|
||||
],
|
||||
totalInvoiceAmount: 119.00,
|
||||
totalNetAmount: 100.00,
|
||||
totalVatAmount: 19.00
|
||||
};
|
||||
|
||||
const results = validator.validateFacturX(invoice as EInvoice, FacturXProfile.BASIC);
|
||||
const errors = results.filter(r => r.severity === 'error');
|
||||
|
||||
console.log('BASIC profile validation errors:', errors);
|
||||
expect(errors.length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('Factur-X Validator - BASIC profile missing line items', async () => {
|
||||
const validator = FacturXValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
metadata: {
|
||||
profileId: 'urn:facturx:basic:2017'
|
||||
},
|
||||
accountingDocId: 'INV-2025-001',
|
||||
issueDate: new Date('2025-01-11'),
|
||||
accountingDocType: 'invoice',
|
||||
currency: 'EUR',
|
||||
dueDate: new Date('2025-02-11'),
|
||||
from: {
|
||||
type: 'company',
|
||||
name: 'Test Seller',
|
||||
vatNumber: 'DE123456789',
|
||||
address: 'Test Street 1',
|
||||
country: 'DE'
|
||||
},
|
||||
to: {
|
||||
type: 'company',
|
||||
name: 'Test Buyer',
|
||||
address: 'Buyer Street 1',
|
||||
country: 'FR'
|
||||
},
|
||||
// Missing items
|
||||
totalInvoiceAmount: 119.00,
|
||||
totalNetAmount: 100.00,
|
||||
totalVatAmount: 19.00
|
||||
};
|
||||
|
||||
const results = validator.validateFacturX(invoice as EInvoice);
|
||||
const errors = results.filter(r => r.severity === 'error');
|
||||
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.some(e => e.ruleId === 'FX-BAS-02')).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('Factur-X Validator - BASIC_WL profile (without lines)', async () => {
|
||||
const validator = FacturXValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
metadata: {
|
||||
profileId: 'urn:facturx:basicwl:2017'
|
||||
},
|
||||
accountingDocId: 'INV-2025-001',
|
||||
issueDate: new Date('2025-01-11'),
|
||||
accountingDocType: 'invoice',
|
||||
currency: 'EUR',
|
||||
dueDate: new Date('2025-02-11'),
|
||||
from: {
|
||||
type: 'company',
|
||||
name: 'Test Seller',
|
||||
vatNumber: 'DE123456789',
|
||||
address: 'Test Street 1',
|
||||
country: 'DE'
|
||||
},
|
||||
to: {
|
||||
type: 'company',
|
||||
name: 'Test Buyer',
|
||||
address: 'Buyer Street 1',
|
||||
country: 'FR'
|
||||
},
|
||||
// No items required for BASIC_WL
|
||||
totalInvoiceAmount: 119.00,
|
||||
totalNetAmount: 100.00,
|
||||
totalVatAmount: 19.00
|
||||
};
|
||||
|
||||
const results = validator.validateFacturX(invoice as EInvoice, FacturXProfile.BASIC_WL);
|
||||
const errors = results.filter(r => r.severity === 'error');
|
||||
|
||||
console.log('BASIC_WL profile validation errors:', errors);
|
||||
expect(errors.length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('Factur-X Validator - EN16931 profile validation', async () => {
|
||||
const validator = FacturXValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
metadata: {
|
||||
profileId: 'urn:facturx:en16931:2017',
|
||||
buyerReference: 'REF-12345'
|
||||
},
|
||||
accountingDocId: 'INV-2025-001',
|
||||
issueDate: new Date('2025-01-11'),
|
||||
accountingDocType: 'invoice',
|
||||
currency: 'EUR',
|
||||
dueDate: new Date('2025-02-11'),
|
||||
from: {
|
||||
type: 'company',
|
||||
name: 'Test Seller',
|
||||
vatNumber: 'DE123456789',
|
||||
address: 'Test Street 1',
|
||||
city: 'Berlin',
|
||||
postalCode: '10115',
|
||||
country: 'DE'
|
||||
},
|
||||
to: {
|
||||
type: 'company',
|
||||
name: 'Test Buyer',
|
||||
address: 'Buyer Street 1',
|
||||
city: 'Paris',
|
||||
postalCode: '75001',
|
||||
country: 'FR'
|
||||
},
|
||||
items: [
|
||||
{
|
||||
position: 1,
|
||||
name: 'Test Product',
|
||||
unitQuantity: 1,
|
||||
unitNetPrice: 100.00,
|
||||
unitType: 'C62',
|
||||
vatPercentage: 19,
|
||||
articleNumber: 'ART-001'
|
||||
}
|
||||
],
|
||||
totalInvoiceAmount: 119.00,
|
||||
totalNetAmount: 100.00,
|
||||
totalVatAmount: 19.00
|
||||
};
|
||||
|
||||
const results = validator.validateFacturX(invoice as EInvoice, FacturXProfile.EN16931);
|
||||
const errors = results.filter(r => r.severity === 'error');
|
||||
|
||||
console.log('EN16931 profile validation errors:', errors);
|
||||
expect(errors.length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('Factur-X Validator - EN16931 missing buyer reference', async () => {
|
||||
const validator = FacturXValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
metadata: {
|
||||
profileId: 'urn:facturx:en16931:2017',
|
||||
// Missing buyerReference or purchaseOrderReference
|
||||
},
|
||||
accountingDocId: 'INV-2025-001',
|
||||
issueDate: new Date('2025-01-11'),
|
||||
accountingDocType: 'invoice',
|
||||
currency: 'EUR',
|
||||
from: {
|
||||
type: 'company',
|
||||
name: 'Test Seller',
|
||||
vatNumber: 'DE123456789',
|
||||
address: 'Test Street 1',
|
||||
city: 'Berlin',
|
||||
postalCode: '10115',
|
||||
country: 'DE'
|
||||
},
|
||||
to: {
|
||||
type: 'company',
|
||||
name: 'Test Buyer',
|
||||
address: 'Buyer Street 1',
|
||||
city: 'Paris',
|
||||
postalCode: '75001',
|
||||
country: 'FR'
|
||||
},
|
||||
items: [],
|
||||
totalInvoiceAmount: 0,
|
||||
totalNetAmount: 0,
|
||||
totalVatAmount: 0,
|
||||
dueDate: new Date('2025-02-11')
|
||||
};
|
||||
|
||||
const results = validator.validateFacturX(invoice as EInvoice);
|
||||
const errors = results.filter(r => r.severity === 'error');
|
||||
|
||||
expect(errors.some(e => e.ruleId === 'FX-EN-01')).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('Factur-X Validator - EXTENDED profile validation', async () => {
|
||||
const validator = FacturXValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
metadata: {
|
||||
profileId: 'urn:facturx:extended:2017',
|
||||
extensions: {
|
||||
attachments: [
|
||||
{
|
||||
filename: 'invoice.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
data: 'base64encodeddata'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
accountingDocId: 'INV-2025-001',
|
||||
issueDate: new Date('2025-01-11'),
|
||||
accountingDocType: 'invoice',
|
||||
currency: 'EUR',
|
||||
from: {
|
||||
type: 'company',
|
||||
name: 'Test Seller',
|
||||
vatNumber: 'DE123456789'
|
||||
},
|
||||
to: {
|
||||
type: 'company',
|
||||
name: 'Test Buyer'
|
||||
},
|
||||
totalInvoiceAmount: 119.00
|
||||
};
|
||||
|
||||
const results = validator.validateFacturX(invoice as EInvoice, FacturXProfile.EXTENDED);
|
||||
const errors = results.filter(r => r.severity === 'error');
|
||||
|
||||
console.log('EXTENDED profile validation errors:', errors);
|
||||
expect(errors.length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('Factur-X Validator - EXTENDED profile attachment validation', async () => {
|
||||
const validator = FacturXValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
metadata: {
|
||||
profileId: 'urn:facturx:extended:2017',
|
||||
extensions: {
|
||||
attachments: [
|
||||
{
|
||||
// Missing filename and mimeType
|
||||
data: 'base64encodeddata'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
accountingDocId: 'INV-2025-001',
|
||||
issueDate: new Date('2025-01-11'),
|
||||
accountingDocType: 'invoice',
|
||||
currency: 'EUR',
|
||||
from: {
|
||||
type: 'company',
|
||||
name: 'Test Seller',
|
||||
vatNumber: 'DE123456789'
|
||||
},
|
||||
to: {
|
||||
type: 'company',
|
||||
name: 'Test Buyer'
|
||||
},
|
||||
totalInvoiceAmount: 119.00
|
||||
};
|
||||
|
||||
const results = validator.validateFacturX(invoice as EInvoice);
|
||||
const warnings = results.filter(r => r.severity === 'warning');
|
||||
|
||||
expect(warnings.some(w => w.ruleId === 'FX-EXT-01')).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('Factur-X Validator - ZUGFeRD compatibility', async () => {
|
||||
const validator = FacturXValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
metadata: {
|
||||
profileId: 'urn:zugferd:basic:2017' // ZUGFeRD format
|
||||
}
|
||||
};
|
||||
|
||||
// Should detect as Factur-X (ZUGFeRD is the German name)
|
||||
const profile = validator.detectProfile(invoice as EInvoice);
|
||||
expect(profile).toEqual(FacturXProfile.BASIC);
|
||||
});
|
||||
|
||||
tap.test('Factur-X Validator - profile display names', async () => {
|
||||
const validator = FacturXValidator.create();
|
||||
|
||||
expect(validator.getProfileDisplayName(FacturXProfile.MINIMUM)).toEqual('Factur-X MINIMUM');
|
||||
expect(validator.getProfileDisplayName(FacturXProfile.BASIC)).toEqual('Factur-X BASIC');
|
||||
expect(validator.getProfileDisplayName(FacturXProfile.BASIC_WL)).toEqual('Factur-X BASIC WL');
|
||||
expect(validator.getProfileDisplayName(FacturXProfile.EN16931)).toEqual('Factur-X EN16931');
|
||||
expect(validator.getProfileDisplayName(FacturXProfile.EXTENDED)).toEqual('Factur-X EXTENDED');
|
||||
});
|
||||
|
||||
tap.test('Factur-X Validator - profile compliance levels', async () => {
|
||||
const validator = FacturXValidator.create();
|
||||
|
||||
expect(validator.getProfileComplianceLevel(FacturXProfile.MINIMUM)).toEqual(1);
|
||||
expect(validator.getProfileComplianceLevel(FacturXProfile.BASIC_WL)).toEqual(2);
|
||||
expect(validator.getProfileComplianceLevel(FacturXProfile.BASIC)).toEqual(3);
|
||||
expect(validator.getProfileComplianceLevel(FacturXProfile.EN16931)).toEqual(4);
|
||||
expect(validator.getProfileComplianceLevel(FacturXProfile.EXTENDED)).toEqual(5);
|
||||
});
|
||||
|
||||
tap.test('Factur-X Validator - non-Factur-X invoice skips validation', async () => {
|
||||
const validator = FacturXValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
metadata: {
|
||||
profileId: 'urn:cen.eu:en16931:2017' // Not Factur-X
|
||||
}
|
||||
};
|
||||
|
||||
const results = validator.validateFacturX(invoice as EInvoice);
|
||||
|
||||
expect(results.length).toEqual(0);
|
||||
});
|
||||
|
||||
export default tap.start();
|
219
test/test.integrated-validator.ts
Normal file
219
test/test.integrated-validator.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { MainValidator, createValidator } from '../ts/formats/validation/integrated.validator.js';
|
||||
import { EInvoice } from '../ts/einvoice.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
tap.test('Integrated Validator - Basic validation', async () => {
|
||||
const validator = new MainValidator();
|
||||
|
||||
const invoice = new EInvoice();
|
||||
invoice.invoiceNumber = 'TEST-001';
|
||||
invoice.issueDate = new Date('2025-01-11');
|
||||
invoice.from = {
|
||||
type: 'company',
|
||||
name: 'Test Seller',
|
||||
address: {
|
||||
streetName: 'Test Street',
|
||||
city: 'Berlin',
|
||||
postalCode: '10115',
|
||||
countryCode: 'DE'
|
||||
}
|
||||
};
|
||||
invoice.to = {
|
||||
name: 'Test Buyer',
|
||||
address: {
|
||||
streetName: 'Buyer Street',
|
||||
city: 'Munich',
|
||||
postalCode: '80331',
|
||||
countryCode: 'DE'
|
||||
}
|
||||
};
|
||||
|
||||
const report = await validator.validate(invoice);
|
||||
|
||||
console.log('Basic validation report:');
|
||||
console.log(` Valid: ${report.valid}`);
|
||||
console.log(` Errors: ${report.errorCount}`);
|
||||
console.log(` Warnings: ${report.warningCount}`);
|
||||
console.log(` Coverage: ${report.coverage.toFixed(1)}%`);
|
||||
|
||||
expect(report).toBeDefined();
|
||||
expect(report.errorCount).toBeGreaterThan(0); // Should have errors (missing required fields)
|
||||
});
|
||||
|
||||
tap.test('Integrated Validator - XRechnung detection', async () => {
|
||||
const validator = new MainValidator();
|
||||
|
||||
const invoice = new EInvoice();
|
||||
invoice.metadata = {
|
||||
profileId: 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0',
|
||||
buyerReference: '991-12345678901-23' // Leitweg-ID
|
||||
};
|
||||
invoice.invoiceNumber = 'XR-2025-001';
|
||||
invoice.issueDate = new Date('2025-01-11');
|
||||
|
||||
const report = await validator.validate(invoice);
|
||||
|
||||
console.log('XRechnung validation report:');
|
||||
console.log(` Profile: ${report.profile}`);
|
||||
console.log(` XRechnung errors found: ${
|
||||
report.results.filter(r => r.source === 'XRECHNUNG').length
|
||||
}`);
|
||||
|
||||
expect(report.profile).toInclude('XRECHNUNG');
|
||||
|
||||
// Check for XRechnung-specific validation
|
||||
const xrErrors = report.results.filter(r => r.source === 'XRECHNUNG');
|
||||
expect(xrErrors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('Integrated Validator - Complete valid invoice', async () => {
|
||||
const validator = await createValidator({ enableSchematron: false });
|
||||
|
||||
const invoice = new EInvoice();
|
||||
invoice.accountingDocId = 'INV-2025-001';
|
||||
invoice.accountingDocType = '380';
|
||||
invoice.invoiceNumber = 'INV-2025-001';
|
||||
invoice.issueDate = new Date('2025-01-11');
|
||||
invoice.currencyCode = 'EUR';
|
||||
|
||||
invoice.from = {
|
||||
type: 'company',
|
||||
name: 'Example GmbH',
|
||||
address: {
|
||||
streetName: 'Hauptstraße 1',
|
||||
city: 'Berlin',
|
||||
postalCode: '10115',
|
||||
countryCode: 'DE'
|
||||
},
|
||||
registrationDetails: {
|
||||
vatId: 'DE123456789'
|
||||
}
|
||||
};
|
||||
|
||||
invoice.to = {
|
||||
name: 'Customer AG',
|
||||
address: {
|
||||
streetName: 'Kundenweg 42',
|
||||
city: 'Munich',
|
||||
postalCode: '80331',
|
||||
countryCode: 'DE'
|
||||
}
|
||||
};
|
||||
|
||||
invoice.items = [{
|
||||
title: 'Consulting Services',
|
||||
description: 'Professional consulting',
|
||||
quantity: 10,
|
||||
unitPrice: 100,
|
||||
netAmount: 1000,
|
||||
vatRate: 19,
|
||||
vatAmount: 190,
|
||||
grossAmount: 1190
|
||||
}];
|
||||
|
||||
invoice.metadata = {
|
||||
customizationId: 'urn:cen.eu:en16931:2017',
|
||||
profileId: 'urn:cen.eu:en16931:2017',
|
||||
taxDetails: [{
|
||||
taxPercent: 19,
|
||||
netAmount: 1000,
|
||||
taxAmount: 190
|
||||
}],
|
||||
totals: {
|
||||
lineExtensionAmount: 1000,
|
||||
taxExclusiveAmount: 1000,
|
||||
taxInclusiveAmount: 1190,
|
||||
payableAmount: 1190
|
||||
}
|
||||
};
|
||||
|
||||
const report = await validator.validate(invoice);
|
||||
|
||||
console.log('\nComplete invoice validation:');
|
||||
console.log(validator.formatReport(report));
|
||||
|
||||
// Should have fewer errors with more complete data
|
||||
expect(report.errorCount).toBeLessThan(10);
|
||||
});
|
||||
|
||||
tap.test('Integrated Validator - With XML content', async () => {
|
||||
const validator = await createValidator();
|
||||
|
||||
// Load a sample XML file if available
|
||||
const xmlPath = path.join(
|
||||
process.cwd(),
|
||||
'corpus/xml-rechnung/3.1/ubl/01-01a-INVOICE_ubl.xml'
|
||||
);
|
||||
|
||||
if (fs.existsSync(xmlPath)) {
|
||||
const xmlContent = fs.readFileSync(xmlPath, 'utf-8');
|
||||
const invoice = await EInvoice.fromXML(xmlContent);
|
||||
|
||||
const report = await validator.validateAuto(invoice, xmlContent);
|
||||
|
||||
console.log('\nXML validation with Schematron:');
|
||||
console.log(` Format detected: ${report.format}`);
|
||||
console.log(` Schematron enabled: ${report.schematronEnabled}`);
|
||||
console.log(` Validation sources: ${
|
||||
[...new Set(report.results.map(r => r.source))].join(', ')
|
||||
}`);
|
||||
|
||||
expect(report.format).toBeDefined();
|
||||
} else {
|
||||
console.log('Sample XML not found, skipping XML validation test');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('Integrated Validator - Capabilities check', async () => {
|
||||
const validator = new MainValidator();
|
||||
|
||||
const capabilities = validator.getCapabilities();
|
||||
|
||||
console.log('\nValidator capabilities:');
|
||||
console.log(` Schematron: ${capabilities.schematron ? '✅' : '❌'}`);
|
||||
console.log(` XRechnung: ${capabilities.xrechnung ? '✅' : '❌'}`);
|
||||
console.log(` PEPPOL: ${capabilities.peppol ? '✅' : '❌'}`);
|
||||
console.log(` Calculations: ${capabilities.calculations ? '✅' : '❌'}`);
|
||||
console.log(` Code Lists: ${capabilities.codeLists ? '✅' : '❌'}`);
|
||||
|
||||
expect(capabilities.xrechnung).toBeTrue();
|
||||
expect(capabilities.calculations).toBeTrue();
|
||||
expect(capabilities.codeLists).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('Integrated Validator - Deduplication', async () => {
|
||||
const validator = new MainValidator();
|
||||
|
||||
// Create invoice that will trigger duplicate errors
|
||||
const invoice = new EInvoice();
|
||||
invoice.invoiceNumber = 'TEST-DUP';
|
||||
|
||||
const report = await validator.validate(invoice);
|
||||
|
||||
// Check that duplicates are removed
|
||||
const ruleIds = report.results.map(r => r.ruleId);
|
||||
const uniqueRuleIds = [...new Set(ruleIds)];
|
||||
|
||||
console.log(`\nDeduplication test:`);
|
||||
console.log(` Total results: ${report.results.length}`);
|
||||
console.log(` Unique rule IDs: ${uniqueRuleIds.length}`);
|
||||
|
||||
// Each rule+field combination should appear only once
|
||||
const combinations = new Set();
|
||||
let duplicates = 0;
|
||||
|
||||
for (const result of report.results) {
|
||||
const key = `${result.ruleId}|${result.field || ''}`;
|
||||
if (combinations.has(key)) {
|
||||
duplicates++;
|
||||
}
|
||||
combinations.add(key);
|
||||
}
|
||||
|
||||
console.log(` Duplicate combinations: ${duplicates}`);
|
||||
expect(duplicates).toEqual(0);
|
||||
});
|
||||
|
||||
export default tap.start();
|
328
test/test.peppol-validator.ts
Normal file
328
test/test.peppol-validator.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { PeppolValidator } from '../ts/formats/validation/peppol.validator.js';
|
||||
import type { EInvoice } from '../ts/einvoice.js';
|
||||
|
||||
tap.test('PEPPOL Validator - basic instantiation', async () => {
|
||||
const validator = PeppolValidator.create();
|
||||
expect(validator).toBeInstanceOf(PeppolValidator);
|
||||
|
||||
// Singleton pattern
|
||||
const validator2 = PeppolValidator.create();
|
||||
expect(validator2).toEqual(validator);
|
||||
});
|
||||
|
||||
tap.test('PEPPOL Validator - endpoint ID validation', async () => {
|
||||
const validator = PeppolValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
metadata: {
|
||||
profileId: 'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
|
||||
extensions: {
|
||||
sellerEndpointId: '0088:1234567890128', // Valid GLN
|
||||
buyerEndpointId: '0192:123456789' // Valid Norwegian org
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const results = validator.validatePeppol(invoice as EInvoice);
|
||||
const endpointErrors = results.filter(r => r.ruleId.startsWith('PEPPOL-T00'));
|
||||
|
||||
console.log('Endpoint validation results:', endpointErrors);
|
||||
expect(endpointErrors.length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('PEPPOL Validator - invalid GLN endpoint', async () => {
|
||||
const validator = PeppolValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
metadata: {
|
||||
profileId: 'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
|
||||
extensions: {
|
||||
sellerEndpointId: '0088:123456789012', // Invalid GLN (wrong check digit)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const results = validator.validatePeppol(invoice as EInvoice);
|
||||
const endpointErrors = results.filter(r => r.ruleId === 'PEPPOL-T001');
|
||||
|
||||
expect(endpointErrors.length).toBeGreaterThan(0);
|
||||
expect(endpointErrors[0].message).toInclude('Invalid seller endpoint ID');
|
||||
});
|
||||
|
||||
tap.test('PEPPOL Validator - invalid endpoint format', async () => {
|
||||
const validator = PeppolValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
metadata: {
|
||||
profileId: 'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
|
||||
extensions: {
|
||||
sellerEndpointId: 'invalid-format', // No scheme
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const results = validator.validatePeppol(invoice as EInvoice);
|
||||
const endpointErrors = results.filter(r => r.ruleId === 'PEPPOL-T001');
|
||||
|
||||
expect(endpointErrors.length).toBeGreaterThan(0);
|
||||
expect(endpointErrors[0].severity).toEqual('error');
|
||||
});
|
||||
|
||||
tap.test('PEPPOL Validator - document type validation', async () => {
|
||||
const validator = PeppolValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
metadata: {
|
||||
profileId: 'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
|
||||
extensions: {
|
||||
documentTypeId: 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2::Invoice##urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0::2.1'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const results = validator.validatePeppol(invoice as EInvoice);
|
||||
const docTypeErrors = results.filter(r => r.ruleId === 'PEPPOL-T003');
|
||||
|
||||
expect(docTypeErrors.length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('PEPPOL Validator - process ID validation', async () => {
|
||||
const validator = PeppolValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
metadata: {
|
||||
profileId: 'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
|
||||
extensions: {
|
||||
processId: 'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const results = validator.validatePeppol(invoice as EInvoice);
|
||||
const processErrors = results.filter(r => r.ruleId === 'PEPPOL-T004');
|
||||
|
||||
expect(processErrors.length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('PEPPOL Validator - invalid process ID', async () => {
|
||||
const validator = PeppolValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
metadata: {
|
||||
profileId: 'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
|
||||
extensions: {
|
||||
processId: 'invalid:process:id'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const results = validator.validatePeppol(invoice as EInvoice);
|
||||
const processErrors = results.filter(r => r.ruleId === 'PEPPOL-T004');
|
||||
|
||||
expect(processErrors.length).toBeGreaterThan(0);
|
||||
expect(processErrors[0].severity).toEqual('warning');
|
||||
});
|
||||
|
||||
tap.test('PEPPOL Validator - business rules', async () => {
|
||||
const validator = PeppolValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
metadata: {
|
||||
profileId: 'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
|
||||
// Missing both buyer reference and purchase order reference
|
||||
},
|
||||
from: {
|
||||
type: 'company',
|
||||
name: 'Test Company'
|
||||
// Missing email
|
||||
}
|
||||
};
|
||||
|
||||
const results = validator.validatePeppol(invoice as EInvoice);
|
||||
|
||||
// Should have error for missing buyer reference
|
||||
const buyerRefErrors = results.filter(r => r.ruleId === 'PEPPOL-B-01');
|
||||
expect(buyerRefErrors.length).toBeGreaterThan(0);
|
||||
|
||||
// Should have warning for missing seller email
|
||||
const emailWarnings = results.filter(r => r.ruleId === 'PEPPOL-B-02');
|
||||
expect(emailWarnings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('PEPPOL Validator - buyer reference present', async () => {
|
||||
const validator = PeppolValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
metadata: {
|
||||
profileId: 'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
|
||||
buyerReference: 'REF-12345'
|
||||
}
|
||||
};
|
||||
|
||||
const results = validator.validatePeppol(invoice as EInvoice);
|
||||
const buyerRefErrors = results.filter(r => r.ruleId === 'PEPPOL-B-01');
|
||||
|
||||
expect(buyerRefErrors.length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('PEPPOL Validator - purchase order reference present', async () => {
|
||||
const validator = PeppolValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
metadata: {
|
||||
profileId: 'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
|
||||
extensions: {
|
||||
purchaseOrderReference: 'PO-2025-001'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const results = validator.validatePeppol(invoice as EInvoice);
|
||||
const buyerRefErrors = results.filter(r => r.ruleId === 'PEPPOL-B-01');
|
||||
|
||||
expect(buyerRefErrors.length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('PEPPOL Validator - payment means validation', async () => {
|
||||
const validator = PeppolValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
metadata: {
|
||||
profileId: 'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
|
||||
extensions: {
|
||||
paymentMeans: {
|
||||
paymentMeansCode: '30' // Valid code for credit transfer
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const results = validator.validatePeppol(invoice as EInvoice);
|
||||
const paymentErrors = results.filter(r => r.ruleId === 'PEPPOL-B-04');
|
||||
|
||||
expect(paymentErrors.length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('PEPPOL Validator - invalid payment means', async () => {
|
||||
const validator = PeppolValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
metadata: {
|
||||
profileId: 'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
|
||||
extensions: {
|
||||
paymentMeans: {
|
||||
paymentMeansCode: '999' // Invalid code
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const results = validator.validatePeppol(invoice as EInvoice);
|
||||
const paymentErrors = results.filter(r => r.ruleId === 'PEPPOL-B-04');
|
||||
|
||||
expect(paymentErrors.length).toBeGreaterThan(0);
|
||||
expect(paymentErrors[0].severity).toEqual('error');
|
||||
});
|
||||
|
||||
tap.test('PEPPOL Validator - non-PEPPOL invoice skips validation', async () => {
|
||||
const validator = PeppolValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
metadata: {
|
||||
profileId: 'urn:cen.eu:en16931:2017', // Not PEPPOL
|
||||
}
|
||||
};
|
||||
|
||||
const results = validator.validatePeppol(invoice as EInvoice);
|
||||
|
||||
expect(results.length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('PEPPOL Validator - scheme ID validation', async () => {
|
||||
const validator = PeppolValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
metadata: {
|
||||
profileId: 'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
|
||||
extensions: {
|
||||
buyerPartyId: {
|
||||
schemeId: '0088', // Valid GLN scheme
|
||||
id: '1234567890128'
|
||||
}
|
||||
}
|
||||
},
|
||||
from: {
|
||||
type: 'company',
|
||||
name: 'Test Company',
|
||||
registrationDetails: {
|
||||
partyIdentification: {
|
||||
schemeId: '9906', // Valid IT:VAT scheme
|
||||
id: 'IT12345678901'
|
||||
}
|
||||
}
|
||||
} as any
|
||||
};
|
||||
|
||||
const results = validator.validatePeppol(invoice as EInvoice);
|
||||
const schemeErrors = results.filter(r =>
|
||||
r.ruleId === 'PEPPOL-T005' || r.ruleId === 'PEPPOL-T006'
|
||||
);
|
||||
|
||||
expect(schemeErrors.length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('PEPPOL Validator - invalid scheme ID', async () => {
|
||||
const validator = PeppolValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
metadata: {
|
||||
profileId: 'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
|
||||
extensions: {
|
||||
buyerPartyId: {
|
||||
schemeId: '9999', // Invalid scheme
|
||||
id: '12345'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const results = validator.validatePeppol(invoice as EInvoice);
|
||||
const schemeErrors = results.filter(r => r.ruleId === 'PEPPOL-T006');
|
||||
|
||||
expect(schemeErrors.length).toBeGreaterThan(0);
|
||||
expect(schemeErrors[0].severity).toEqual('warning');
|
||||
});
|
||||
|
||||
tap.test('PEPPOL Validator - B2G detection', async () => {
|
||||
const validator = PeppolValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
metadata: {
|
||||
profileId: 'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
|
||||
extensions: {
|
||||
buyerPartyId: {
|
||||
schemeId: '0204', // German government Leitweg-ID
|
||||
id: '991-12345-01'
|
||||
},
|
||||
buyerCategory: 'government'
|
||||
}
|
||||
},
|
||||
to: {
|
||||
type: 'company',
|
||||
name: 'Government Agency'
|
||||
}
|
||||
};
|
||||
|
||||
const results = validator.validatePeppol(invoice as EInvoice);
|
||||
|
||||
// B2G should require endpoint IDs
|
||||
const endpointErrors = results.filter(r =>
|
||||
r.ruleId === 'PEPPOL-T001' || r.ruleId === 'PEPPOL-T002'
|
||||
);
|
||||
|
||||
expect(endpointErrors.length).toBeGreaterThan(0);
|
||||
expect(endpointErrors[0].message).toInclude('mandatory for PEPPOL B2G');
|
||||
});
|
||||
|
||||
export default tap.start();
|
654
test/test.semantic-model.ts
Normal file
654
test/test.semantic-model.ts
Normal file
@@ -0,0 +1,654 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { SemanticModelValidator } from '../ts/formats/semantic/semantic.validator.js';
|
||||
import { SemanticModelAdapter } from '../ts/formats/semantic/semantic.adapter.js';
|
||||
import { EInvoice } from '../ts/einvoice.js';
|
||||
import type { EN16931SemanticModel } from '../ts/formats/semantic/bt-bg.model.js';
|
||||
|
||||
tap.test('Semantic Model - adapter instantiation', async () => {
|
||||
const adapter = new SemanticModelAdapter();
|
||||
expect(adapter).toBeInstanceOf(SemanticModelAdapter);
|
||||
|
||||
const validator = new SemanticModelValidator();
|
||||
expect(validator).toBeInstanceOf(SemanticModelValidator);
|
||||
});
|
||||
|
||||
tap.test('Semantic Model - EInvoice to semantic model conversion', async () => {
|
||||
const adapter = new SemanticModelAdapter();
|
||||
|
||||
const invoice = new EInvoice();
|
||||
invoice.accountingDocId = 'INV-2025-001';
|
||||
invoice.issueDate = new Date('2025-01-11');
|
||||
invoice.accountingDocType = 'invoice';
|
||||
invoice.currency = 'EUR';
|
||||
|
||||
invoice.from = {
|
||||
type: 'company',
|
||||
name: 'Test Seller GmbH',
|
||||
address: {
|
||||
streetName: 'Hauptstrasse 1',
|
||||
houseNumber: '',
|
||||
city: 'Berlin',
|
||||
postalCode: '10115',
|
||||
country: 'DE'
|
||||
},
|
||||
registrationDetails: {
|
||||
vatId: 'DE123456789',
|
||||
registrationId: '',
|
||||
registrationName: 'Test Seller GmbH'
|
||||
},
|
||||
status: 'active',
|
||||
foundedDate: {
|
||||
year: 2024,
|
||||
month: 1,
|
||||
day: 1
|
||||
}
|
||||
} as any;
|
||||
|
||||
invoice.to = {
|
||||
type: 'company',
|
||||
name: 'Test Buyer SAS',
|
||||
address: {
|
||||
streetName: 'Rue de la Paix 10',
|
||||
houseNumber: '',
|
||||
city: 'Paris',
|
||||
postalCode: '75001',
|
||||
country: 'FR'
|
||||
},
|
||||
registrationDetails: {
|
||||
vatId: 'FR987654321',
|
||||
registrationId: '',
|
||||
registrationName: 'Test Buyer SAS'
|
||||
},
|
||||
status: 'active',
|
||||
foundedDate: {
|
||||
year: 2024,
|
||||
month: 1,
|
||||
day: 1
|
||||
}
|
||||
} as any;
|
||||
|
||||
invoice.items = [{
|
||||
position: 1,
|
||||
name: 'Consulting Service',
|
||||
unitQuantity: 10,
|
||||
unitNetPrice: 100,
|
||||
vatPercentage: 19,
|
||||
unitType: 'HUR',
|
||||
articleNumber: '',
|
||||
description: 'Professional consulting services'
|
||||
}];
|
||||
|
||||
const model = adapter.toSemanticModel(invoice);
|
||||
|
||||
// Verify core fields
|
||||
expect(model.documentInformation.invoiceNumber).toEqual('INV-2025-001');
|
||||
expect(model.documentInformation.currencyCode).toEqual('EUR');
|
||||
expect(model.documentInformation.typeCode).toEqual('380'); // Invoice type code
|
||||
|
||||
// Verify seller
|
||||
expect(model.seller.name).toEqual('Test Seller GmbH');
|
||||
expect(model.seller.vatIdentifier).toEqual('DE123456789');
|
||||
expect(model.seller.postalAddress.countryCode).toEqual('DE');
|
||||
|
||||
// Verify buyer
|
||||
expect(model.buyer.name).toEqual('Test Buyer SAS');
|
||||
expect(model.buyer.vatIdentifier).toEqual('FR987654321');
|
||||
expect(model.buyer.postalAddress.countryCode).toEqual('FR');
|
||||
|
||||
// Verify lines
|
||||
expect(model.invoiceLines.length).toEqual(1);
|
||||
expect(model.invoiceLines[0].itemInformation.name).toEqual('Consulting Service');
|
||||
expect(model.invoiceLines[0].invoicedQuantity).toEqual(10);
|
||||
});
|
||||
|
||||
tap.test('Semantic Model - semantic model to EInvoice conversion', async () => {
|
||||
const adapter = new SemanticModelAdapter();
|
||||
|
||||
const model: EN16931SemanticModel = {
|
||||
documentInformation: {
|
||||
invoiceNumber: 'INV-2025-002',
|
||||
issueDate: new Date('2025-01-11'),
|
||||
typeCode: '380',
|
||||
currencyCode: 'USD'
|
||||
},
|
||||
seller: {
|
||||
name: 'US Seller Inc',
|
||||
vatIdentifier: 'US123456789',
|
||||
postalAddress: {
|
||||
addressLine1: '123 Main St',
|
||||
city: 'New York',
|
||||
postCode: '10001',
|
||||
countryCode: 'US'
|
||||
}
|
||||
},
|
||||
buyer: {
|
||||
name: 'Canadian Buyer Ltd',
|
||||
vatIdentifier: 'CA987654321',
|
||||
postalAddress: {
|
||||
addressLine1: '456 Queen St',
|
||||
city: 'Toronto',
|
||||
postCode: 'M5H 2N2',
|
||||
countryCode: 'CA'
|
||||
}
|
||||
},
|
||||
paymentInstructions: {
|
||||
paymentMeansTypeCode: '30',
|
||||
paymentAccountIdentifier: 'US12345678901234567890'
|
||||
},
|
||||
documentTotals: {
|
||||
lineExtensionAmount: 1000,
|
||||
taxExclusiveAmount: 1000,
|
||||
taxInclusiveAmount: 1100,
|
||||
payableAmount: 1100
|
||||
},
|
||||
invoiceLines: [{
|
||||
identifier: '1',
|
||||
invoicedQuantity: 5,
|
||||
invoicedQuantityUnitOfMeasureCode: 'C62',
|
||||
lineExtensionAmount: 1000,
|
||||
priceDetails: {
|
||||
itemNetPrice: 200
|
||||
},
|
||||
vatInformation: {
|
||||
categoryCode: 'S',
|
||||
rate: 10
|
||||
},
|
||||
itemInformation: {
|
||||
name: 'Product A',
|
||||
description: 'High quality product'
|
||||
}
|
||||
}]
|
||||
};
|
||||
|
||||
const invoice = adapter.fromSemanticModel(model);
|
||||
|
||||
expect(invoice.accountingDocId).toEqual('INV-2025-002');
|
||||
expect(invoice.currency).toEqual('USD');
|
||||
expect(invoice.accountingDocType).toEqual('invoice');
|
||||
expect(invoice.from.name).toEqual('US Seller Inc');
|
||||
expect(invoice.to.name).toEqual('Canadian Buyer Ltd');
|
||||
expect(invoice.items.length).toEqual(1);
|
||||
expect(invoice.items[0].name).toEqual('Product A');
|
||||
});
|
||||
|
||||
tap.test('Semantic Model - validation of mandatory business terms', async () => {
|
||||
const validator = new SemanticModelValidator();
|
||||
|
||||
// Invalid invoice missing mandatory fields
|
||||
const invoice = new EInvoice();
|
||||
invoice.accountingDocId = ''; // Missing invoice number
|
||||
invoice.issueDate = new Date('2025-01-11');
|
||||
invoice.accountingDocType = 'invoice';
|
||||
invoice.currency = 'EUR';
|
||||
|
||||
invoice.from = {
|
||||
type: 'company',
|
||||
name: 'Test Seller',
|
||||
address: {
|
||||
streetName: '',
|
||||
houseNumber: '',
|
||||
city: '',
|
||||
postalCode: '',
|
||||
country: 'DE'
|
||||
},
|
||||
registrationDetails: {
|
||||
vatId: '',
|
||||
registrationId: '',
|
||||
registrationName: 'Test Seller'
|
||||
},
|
||||
status: 'active',
|
||||
foundedDate: {
|
||||
year: 2024,
|
||||
month: 1,
|
||||
day: 1
|
||||
}
|
||||
} as any;
|
||||
|
||||
invoice.to = {
|
||||
type: 'company',
|
||||
name: 'Test Buyer',
|
||||
address: {
|
||||
streetName: '',
|
||||
houseNumber: '',
|
||||
city: '',
|
||||
postalCode: '',
|
||||
country: 'FR'
|
||||
},
|
||||
registrationDetails: {
|
||||
vatId: '',
|
||||
registrationId: '',
|
||||
registrationName: 'Test Buyer'
|
||||
},
|
||||
status: 'active',
|
||||
foundedDate: {
|
||||
year: 2024,
|
||||
month: 1,
|
||||
day: 1
|
||||
}
|
||||
} as any;
|
||||
|
||||
invoice.items = [];
|
||||
|
||||
const results = validator.validate(invoice);
|
||||
|
||||
// Should have errors for missing mandatory fields
|
||||
const errors = results.filter(r => r.severity === 'error');
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
|
||||
// Check for specific BT errors
|
||||
expect(errors.some(e => e.btReference === 'BT-1')).toBeTrue(); // Invoice number
|
||||
expect(errors.some(e => e.bgReference === 'BG-25')).toBeTrue(); // Invoice lines
|
||||
});
|
||||
|
||||
tap.test('Semantic Model - validation of valid invoice', async () => {
|
||||
const validator = new SemanticModelValidator();
|
||||
|
||||
const invoice = new EInvoice();
|
||||
invoice.accountingDocId = 'INV-2025-003';
|
||||
invoice.issueDate = new Date('2025-01-11');
|
||||
invoice.accountingDocType = 'invoice';
|
||||
invoice.currency = 'EUR';
|
||||
|
||||
invoice.from = {
|
||||
type: 'company',
|
||||
name: 'Valid Seller GmbH',
|
||||
address: {
|
||||
streetName: 'Hauptstrasse 1',
|
||||
houseNumber: '',
|
||||
city: 'Berlin',
|
||||
postalCode: '10115',
|
||||
country: 'DE'
|
||||
},
|
||||
registrationDetails: {
|
||||
vatId: 'DE123456789',
|
||||
registrationId: '',
|
||||
registrationName: 'Valid Seller GmbH'
|
||||
},
|
||||
status: 'active',
|
||||
foundedDate: {
|
||||
year: 2024,
|
||||
month: 1,
|
||||
day: 1
|
||||
}
|
||||
} as any;
|
||||
|
||||
invoice.to = {
|
||||
type: 'company',
|
||||
name: 'Valid Buyer SAS',
|
||||
address: {
|
||||
streetName: 'Rue de la Paix 10',
|
||||
houseNumber: '',
|
||||
city: 'Paris',
|
||||
postalCode: '75001',
|
||||
country: 'FR'
|
||||
},
|
||||
registrationDetails: {
|
||||
vatId: 'FR987654321',
|
||||
registrationId: '',
|
||||
registrationName: 'Valid Buyer SAS'
|
||||
},
|
||||
status: 'active',
|
||||
foundedDate: {
|
||||
year: 2024,
|
||||
month: 1,
|
||||
day: 1
|
||||
}
|
||||
} as any;
|
||||
|
||||
invoice.items = [{
|
||||
position: 1,
|
||||
name: 'Consulting Service',
|
||||
unitQuantity: 10,
|
||||
unitNetPrice: 100,
|
||||
vatPercentage: 19,
|
||||
unitType: 'HUR',
|
||||
articleNumber: '',
|
||||
description: 'Professional consulting services'
|
||||
}];
|
||||
|
||||
invoice.paymentAccount = {
|
||||
iban: 'DE89370400440532013000',
|
||||
institutionName: 'Test Bank'
|
||||
} as any;
|
||||
|
||||
const results = validator.validate(invoice);
|
||||
const errors = results.filter(r => r.severity === 'error');
|
||||
|
||||
console.log('Validation errors:', errors);
|
||||
|
||||
// Should have minimal or no errors for a valid invoice
|
||||
expect(errors.length).toBeLessThanOrEqual(1); // Allow for payment means type code
|
||||
});
|
||||
|
||||
tap.test('Semantic Model - BT/BG mapping', async () => {
|
||||
const validator = new SemanticModelValidator();
|
||||
|
||||
const invoice = new EInvoice();
|
||||
invoice.accountingDocId = 'INV-2025-004';
|
||||
invoice.issueDate = new Date('2025-01-11');
|
||||
invoice.accountingDocType = 'invoice';
|
||||
invoice.currency = 'EUR';
|
||||
|
||||
invoice.from = {
|
||||
type: 'company',
|
||||
name: 'Mapping Test Seller',
|
||||
address: {
|
||||
streetName: '',
|
||||
houseNumber: '',
|
||||
city: '',
|
||||
postalCode: '',
|
||||
country: 'DE'
|
||||
},
|
||||
registrationDetails: {
|
||||
vatId: '',
|
||||
registrationId: '',
|
||||
registrationName: 'Mapping Test Seller'
|
||||
},
|
||||
status: 'active',
|
||||
foundedDate: {
|
||||
year: 2024,
|
||||
month: 1,
|
||||
day: 1
|
||||
}
|
||||
} as any;
|
||||
|
||||
invoice.to = {
|
||||
type: 'company',
|
||||
name: 'Mapping Test Buyer',
|
||||
address: {
|
||||
streetName: '',
|
||||
houseNumber: '',
|
||||
city: '',
|
||||
postalCode: '',
|
||||
country: 'FR'
|
||||
},
|
||||
registrationDetails: {
|
||||
vatId: '',
|
||||
registrationId: '',
|
||||
registrationName: 'Mapping Test Buyer'
|
||||
},
|
||||
status: 'active',
|
||||
foundedDate: {
|
||||
year: 2024,
|
||||
month: 1,
|
||||
day: 1
|
||||
}
|
||||
} as any;
|
||||
|
||||
invoice.items = [{
|
||||
position: 1,
|
||||
name: 'Test Item',
|
||||
unitQuantity: 1,
|
||||
unitNetPrice: 100,
|
||||
vatPercentage: 19,
|
||||
unitType: 'C62',
|
||||
articleNumber: '',
|
||||
description: 'Test item description'
|
||||
}];
|
||||
|
||||
const mapping = validator.getBusinessTermMapping(invoice);
|
||||
|
||||
// Verify key mappings
|
||||
expect(mapping.get('BT-1')).toEqual('INV-2025-004');
|
||||
expect(mapping.get('BT-5')).toEqual('EUR');
|
||||
expect(mapping.get('BT-27')).toEqual('Mapping Test Seller');
|
||||
expect(mapping.get('BT-44')).toEqual('Mapping Test Buyer');
|
||||
expect(mapping.has('BG-25')).toBeTrue(); // Invoice lines
|
||||
|
||||
const invoiceLines = mapping.get('BG-25');
|
||||
expect(invoiceLines.length).toEqual(1);
|
||||
});
|
||||
|
||||
tap.test('Semantic Model - credit note validation', async () => {
|
||||
const validator = new SemanticModelValidator();
|
||||
|
||||
const creditNote = new EInvoice();
|
||||
creditNote.accountingDocId = 'CN-2025-001';
|
||||
creditNote.issueDate = new Date('2025-01-11');
|
||||
creditNote.accountingDocType = 'creditNote';
|
||||
creditNote.currency = 'EUR';
|
||||
|
||||
creditNote.from = {
|
||||
type: 'company',
|
||||
name: 'Credit Issuer',
|
||||
address: {
|
||||
streetName: '',
|
||||
houseNumber: '',
|
||||
city: '',
|
||||
postalCode: '',
|
||||
country: 'DE'
|
||||
},
|
||||
registrationDetails: {
|
||||
vatId: '',
|
||||
registrationId: '',
|
||||
registrationName: 'Credit Issuer'
|
||||
},
|
||||
status: 'active',
|
||||
foundedDate: {
|
||||
year: 2024,
|
||||
month: 1,
|
||||
day: 1
|
||||
}
|
||||
} as any;
|
||||
|
||||
creditNote.to = {
|
||||
type: 'company',
|
||||
name: 'Credit Receiver',
|
||||
address: {
|
||||
streetName: '',
|
||||
houseNumber: '',
|
||||
city: '',
|
||||
postalCode: '',
|
||||
country: 'FR'
|
||||
},
|
||||
registrationDetails: {
|
||||
vatId: '',
|
||||
registrationId: '',
|
||||
registrationName: 'Credit Receiver'
|
||||
},
|
||||
status: 'active',
|
||||
foundedDate: {
|
||||
year: 2024,
|
||||
month: 1,
|
||||
day: 1
|
||||
}
|
||||
} as any;
|
||||
|
||||
creditNote.items = [{
|
||||
position: 1,
|
||||
name: 'Refund Item',
|
||||
unitQuantity: -1,
|
||||
unitNetPrice: 100,
|
||||
vatPercentage: 19,
|
||||
unitType: 'C62',
|
||||
articleNumber: '',
|
||||
description: 'Refund for returned goods'
|
||||
}];
|
||||
|
||||
const results = validator.validate(creditNote);
|
||||
|
||||
// Should have warning about missing preceding invoice reference
|
||||
const warnings = results.filter(r => r.severity === 'warning');
|
||||
expect(warnings.some(w => w.ruleId === 'COND-02')).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('Semantic Model - VAT breakdown validation', async () => {
|
||||
const adapter = new SemanticModelAdapter();
|
||||
|
||||
const invoice = new EInvoice();
|
||||
invoice.accountingDocId = 'INV-2025-005';
|
||||
invoice.issueDate = new Date('2025-01-11');
|
||||
invoice.accountingDocType = 'invoice';
|
||||
invoice.currency = 'EUR';
|
||||
|
||||
invoice.from = {
|
||||
type: 'company',
|
||||
name: 'VAT Test Seller',
|
||||
address: {
|
||||
streetName: '',
|
||||
houseNumber: '',
|
||||
city: '',
|
||||
postalCode: '',
|
||||
country: 'DE'
|
||||
},
|
||||
registrationDetails: {
|
||||
vatId: '',
|
||||
registrationId: '',
|
||||
registrationName: 'VAT Test Seller'
|
||||
},
|
||||
status: 'active',
|
||||
foundedDate: {
|
||||
year: 2024,
|
||||
month: 1,
|
||||
day: 1
|
||||
}
|
||||
} as any;
|
||||
|
||||
invoice.to = {
|
||||
type: 'company',
|
||||
name: 'VAT Test Buyer',
|
||||
address: {
|
||||
streetName: '',
|
||||
houseNumber: '',
|
||||
city: '',
|
||||
postalCode: '',
|
||||
country: 'FR'
|
||||
},
|
||||
registrationDetails: {
|
||||
vatId: '',
|
||||
registrationId: '',
|
||||
registrationName: 'VAT Test Buyer'
|
||||
},
|
||||
status: 'active',
|
||||
foundedDate: {
|
||||
year: 2024,
|
||||
month: 1,
|
||||
day: 1
|
||||
}
|
||||
} as any;
|
||||
|
||||
invoice.items = [
|
||||
{
|
||||
position: 1,
|
||||
name: 'Standard Rate Item',
|
||||
unitQuantity: 1,
|
||||
unitNetPrice: 100,
|
||||
vatPercentage: 19,
|
||||
unitType: 'C62',
|
||||
articleNumber: '',
|
||||
description: 'Product with standard VAT rate'
|
||||
},
|
||||
{
|
||||
position: 2,
|
||||
name: 'Zero Rate Item',
|
||||
unitQuantity: 1,
|
||||
unitNetPrice: 50,
|
||||
vatPercentage: 0,
|
||||
unitType: 'C62',
|
||||
articleNumber: '',
|
||||
description: 'Product with zero VAT rate'
|
||||
}
|
||||
];
|
||||
|
||||
const model = adapter.toSemanticModel(invoice);
|
||||
|
||||
// Should create VAT breakdown
|
||||
expect(model.vatBreakdown).toBeDefined();
|
||||
if (model.vatBreakdown) {
|
||||
// Default implementation creates single breakdown from totals
|
||||
expect(model.vatBreakdown.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('Semantic Model - complete semantic model validation', async () => {
|
||||
const adapter = new SemanticModelAdapter();
|
||||
|
||||
const model: EN16931SemanticModel = {
|
||||
documentInformation: {
|
||||
invoiceNumber: 'COMPLETE-001',
|
||||
issueDate: new Date('2025-01-11'),
|
||||
typeCode: '380',
|
||||
currencyCode: 'EUR',
|
||||
notes: [{ noteContent: 'Test invoice' }]
|
||||
},
|
||||
processControl: {
|
||||
specificationIdentifier: 'urn:cen.eu:en16931:2017'
|
||||
},
|
||||
references: {
|
||||
buyerReference: 'REF-12345',
|
||||
purchaseOrderReference: 'PO-2025-001'
|
||||
},
|
||||
seller: {
|
||||
name: 'Complete Seller GmbH',
|
||||
vatIdentifier: 'DE123456789',
|
||||
legalRegistrationIdentifier: 'HRB 12345',
|
||||
postalAddress: {
|
||||
addressLine1: 'Hauptstrasse 1',
|
||||
city: 'Berlin',
|
||||
postCode: '10115',
|
||||
countryCode: 'DE'
|
||||
},
|
||||
contact: {
|
||||
contactPoint: 'John Doe',
|
||||
telephoneNumber: '+49 30 12345678',
|
||||
emailAddress: 'john@seller.de'
|
||||
}
|
||||
},
|
||||
buyer: {
|
||||
name: 'Complete Buyer SAS',
|
||||
vatIdentifier: 'FR987654321',
|
||||
postalAddress: {
|
||||
addressLine1: 'Rue de la Paix 10',
|
||||
city: 'Paris',
|
||||
postCode: '75001',
|
||||
countryCode: 'FR'
|
||||
}
|
||||
},
|
||||
delivery: {
|
||||
name: 'Delivery Location',
|
||||
actualDeliveryDate: new Date('2025-01-10')
|
||||
},
|
||||
paymentInstructions: {
|
||||
paymentMeansTypeCode: '30',
|
||||
paymentAccountIdentifier: 'DE89370400440532013000',
|
||||
paymentServiceProviderIdentifier: 'COBADEFFXXX'
|
||||
},
|
||||
documentTotals: {
|
||||
lineExtensionAmount: 1000,
|
||||
taxExclusiveAmount: 1000,
|
||||
taxInclusiveAmount: 1190,
|
||||
payableAmount: 1190
|
||||
},
|
||||
vatBreakdown: [{
|
||||
vatCategoryTaxableAmount: 1000,
|
||||
vatCategoryTaxAmount: 190,
|
||||
vatCategoryCode: 'S',
|
||||
vatCategoryRate: 19
|
||||
}],
|
||||
invoiceLines: [{
|
||||
identifier: '1',
|
||||
invoicedQuantity: 10,
|
||||
invoicedQuantityUnitOfMeasureCode: 'HUR',
|
||||
lineExtensionAmount: 1000,
|
||||
priceDetails: {
|
||||
itemNetPrice: 100
|
||||
},
|
||||
vatInformation: {
|
||||
categoryCode: 'S',
|
||||
rate: 19
|
||||
},
|
||||
itemInformation: {
|
||||
name: 'Professional Services',
|
||||
description: 'Consulting and implementation'
|
||||
}
|
||||
}]
|
||||
};
|
||||
|
||||
// Validate the model
|
||||
const errors = adapter.validateSemanticModel(model);
|
||||
|
||||
console.log('Semantic model validation errors:', errors);
|
||||
expect(errors.length).toEqual(0);
|
||||
});
|
||||
|
||||
export default tap.start();
|
368
test/test.xrechnung-validator.ts
Normal file
368
test/test.xrechnung-validator.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { XRechnungValidator } from '../ts/formats/validation/xrechnung.validator.js';
|
||||
import type { EInvoice } from '../ts/einvoice.js';
|
||||
|
||||
tap.test('XRechnungValidator - Leitweg-ID validation', async () => {
|
||||
const validator = XRechnungValidator.create();
|
||||
|
||||
// Create test invoice with XRechnung profile
|
||||
const invoice: Partial<EInvoice> = {
|
||||
invoiceNumber: 'INV-2025-001',
|
||||
metadata: {
|
||||
profileId: 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0',
|
||||
buyerReference: '04-123456789012-01'
|
||||
}
|
||||
};
|
||||
|
||||
const results = validator.validateXRechnung(invoice as EInvoice);
|
||||
|
||||
// Valid Leitweg-ID should pass
|
||||
const leitwegErrors = results.filter(r => r.ruleId === 'XR-DE-01');
|
||||
expect(leitwegErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
tap.test('XRechnungValidator - Invalid Leitweg-ID', async () => {
|
||||
const validator = XRechnungValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
invoiceNumber: 'INV-2025-002',
|
||||
metadata: {
|
||||
profileId: 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0',
|
||||
buyerReference: '4-12345-1' // Invalid format
|
||||
}
|
||||
};
|
||||
|
||||
const results = validator.validateXRechnung(invoice as EInvoice);
|
||||
|
||||
// Should have Leitweg-ID format error
|
||||
const leitwegErrors = results.filter(r => r.ruleId === 'XR-DE-01');
|
||||
expect(leitwegErrors).toHaveLength(1);
|
||||
expect(leitwegErrors[0].severity).toEqual('error');
|
||||
});
|
||||
|
||||
tap.test('XRechnungValidator - IBAN validation', async () => {
|
||||
const validator = XRechnungValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
invoiceNumber: 'INV-2025-003',
|
||||
metadata: {
|
||||
profileId: 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0',
|
||||
buyerReference: 'REF-123',
|
||||
extensions: {
|
||||
paymentMeans: [
|
||||
{
|
||||
type: 'SEPA',
|
||||
iban: 'DE89370400440532013000', // Valid German IBAN
|
||||
bic: 'COBADEFFXXX'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const results = validator.validateXRechnung(invoice as EInvoice);
|
||||
|
||||
// Valid IBAN should pass
|
||||
const ibanErrors = results.filter(r => r.ruleId === 'XR-DE-19');
|
||||
expect(ibanErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
tap.test('XRechnungValidator - Invalid IBAN checksum', async () => {
|
||||
const validator = XRechnungValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
invoiceNumber: 'INV-2025-004',
|
||||
metadata: {
|
||||
profileId: 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0',
|
||||
buyerReference: 'REF-124',
|
||||
extensions: {
|
||||
paymentMeans: [
|
||||
{
|
||||
type: 'SEPA',
|
||||
iban: 'DE89370400440532013001' // Invalid checksum
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const results = validator.validateXRechnung(invoice as EInvoice);
|
||||
|
||||
// Should have IBAN checksum error
|
||||
const ibanErrors = results.filter(r => r.ruleId === 'XR-DE-19');
|
||||
expect(ibanErrors).toHaveLength(1);
|
||||
expect(ibanErrors[0].message).toInclude('Invalid IBAN checksum');
|
||||
});
|
||||
|
||||
tap.test('XRechnungValidator - BIC validation', async () => {
|
||||
const validator = XRechnungValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
invoiceNumber: 'INV-2025-005',
|
||||
metadata: {
|
||||
profileId: 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0',
|
||||
buyerReference: 'REF-125',
|
||||
extensions: {
|
||||
paymentMeans: [
|
||||
{
|
||||
type: 'SEPA',
|
||||
iban: 'DE89370400440532013000',
|
||||
bic: 'COBADEFF' // Valid 8-character BIC
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const results = validator.validateXRechnung(invoice as EInvoice);
|
||||
|
||||
// Valid BIC should pass
|
||||
const bicErrors = results.filter(r => r.ruleId === 'XR-DE-20');
|
||||
expect(bicErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
tap.test('XRechnungValidator - Invalid BIC format', async () => {
|
||||
const validator = XRechnungValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
invoiceNumber: 'INV-2025-006',
|
||||
metadata: {
|
||||
profileId: 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0',
|
||||
buyerReference: 'REF-126',
|
||||
extensions: {
|
||||
paymentMeans: [
|
||||
{
|
||||
type: 'SEPA',
|
||||
iban: 'DE89370400440532013000',
|
||||
bic: 'INVALID' // Invalid BIC format
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const results = validator.validateXRechnung(invoice as EInvoice);
|
||||
|
||||
// Should have BIC format error
|
||||
const bicErrors = results.filter(r => r.ruleId === 'XR-DE-20');
|
||||
expect(bicErrors).toHaveLength(1);
|
||||
expect(bicErrors[0].message).toInclude('Invalid BIC format');
|
||||
});
|
||||
|
||||
tap.test('XRechnungValidator - Mandatory buyer reference', async () => {
|
||||
const validator = XRechnungValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
invoiceNumber: 'INV-2025-007',
|
||||
metadata: {
|
||||
profileId: 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0'
|
||||
// Missing buyerReference
|
||||
}
|
||||
};
|
||||
|
||||
const results = validator.validateXRechnung(invoice as EInvoice);
|
||||
|
||||
// Should have mandatory buyer reference error
|
||||
const refErrors = results.filter(r => r.ruleId === 'XR-DE-15');
|
||||
expect(refErrors).toHaveLength(1);
|
||||
expect(refErrors[0].severity).toEqual('error');
|
||||
});
|
||||
|
||||
tap.test('XRechnungValidator - Seller contact validation', async () => {
|
||||
const validator = XRechnungValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
invoiceNumber: 'INV-2025-008',
|
||||
metadata: {
|
||||
profileId: 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0',
|
||||
buyerReference: 'REF-127',
|
||||
extensions: {
|
||||
sellerContact: {
|
||||
name: 'John Doe',
|
||||
email: 'john.doe@example.com',
|
||||
phone: '+49 30 12345678'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const results = validator.validateXRechnung(invoice as EInvoice);
|
||||
|
||||
// Valid seller contact should pass
|
||||
const contactErrors = results.filter(r => r.ruleId === 'XR-DE-02');
|
||||
expect(contactErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
tap.test('XRechnungValidator - Missing seller contact', async () => {
|
||||
const validator = XRechnungValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
invoiceNumber: 'INV-2025-009',
|
||||
metadata: {
|
||||
profileId: 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0',
|
||||
buyerReference: 'REF-128'
|
||||
// Missing sellerContact
|
||||
}
|
||||
};
|
||||
|
||||
const results = validator.validateXRechnung(invoice as EInvoice);
|
||||
|
||||
// Should have missing seller contact error
|
||||
const contactErrors = results.filter(r => r.ruleId === 'XR-DE-02');
|
||||
expect(contactErrors).toHaveLength(1);
|
||||
expect(contactErrors[0].severity).toEqual('error');
|
||||
});
|
||||
|
||||
tap.test('XRechnungValidator - German VAT ID validation', async () => {
|
||||
const validator = XRechnungValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
invoiceNumber: 'INV-2025-010',
|
||||
from: {
|
||||
type: 'company' as const,
|
||||
name: 'Test Company',
|
||||
registrationDetails: {
|
||||
vatId: 'DE123456789' // Valid German VAT ID format
|
||||
}
|
||||
},
|
||||
metadata: {
|
||||
profileId: 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0',
|
||||
buyerReference: 'REF-129',
|
||||
sellerTaxId: 'DE123456789'
|
||||
}
|
||||
};
|
||||
|
||||
const results = validator.validateXRechnung(invoice as EInvoice);
|
||||
|
||||
// Valid German VAT ID should pass
|
||||
const vatErrors = results.filter(r => r.ruleId === 'XR-DE-04');
|
||||
expect(vatErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
tap.test('XRechnungValidator - Invalid German VAT ID', async () => {
|
||||
const validator = XRechnungValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
invoiceNumber: 'INV-2025-011',
|
||||
metadata: {
|
||||
profileId: 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0',
|
||||
buyerReference: 'REF-130',
|
||||
sellerTaxId: 'DE12345' // Invalid - too short
|
||||
}
|
||||
};
|
||||
|
||||
const results = validator.validateXRechnung(invoice as EInvoice);
|
||||
|
||||
// Should have invalid VAT ID error
|
||||
const vatErrors = results.filter(r => r.ruleId === 'XR-DE-04');
|
||||
expect(vatErrors).toHaveLength(1);
|
||||
expect(vatErrors[0].message).toInclude('Invalid German VAT ID format');
|
||||
});
|
||||
|
||||
tap.test('XRechnungValidator - Non-XRechnung invoice', async () => {
|
||||
const validator = XRechnungValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
invoiceNumber: 'INV-2025-012',
|
||||
metadata: {
|
||||
profileId: 'urn:cen.eu:en16931:2017' // Not XRechnung
|
||||
}
|
||||
};
|
||||
|
||||
const results = validator.validateXRechnung(invoice as EInvoice);
|
||||
|
||||
// Should not validate non-XRechnung invoices
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
tap.test('XRechnungValidator - SEPA country validation', async () => {
|
||||
const validator = XRechnungValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
invoiceNumber: 'INV-2025-013',
|
||||
metadata: {
|
||||
profileId: 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0',
|
||||
buyerReference: 'REF-131',
|
||||
extensions: {
|
||||
paymentMeans: [
|
||||
{
|
||||
type: 'SEPA',
|
||||
iban: 'US12345678901234567890123456789' // Non-SEPA country
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const results = validator.validateXRechnung(invoice as EInvoice);
|
||||
|
||||
// Should have warning for non-SEPA country
|
||||
const sepaWarnings = results.filter(r => r.ruleId === 'XR-DE-19' && r.severity === 'warning');
|
||||
expect(sepaWarnings.length).toBeGreaterThan(0);
|
||||
expect(sepaWarnings[0].message).toInclude('not in SEPA zone');
|
||||
});
|
||||
|
||||
tap.test('XRechnungValidator - B2G Leitweg-ID requirement', async () => {
|
||||
const validator = XRechnungValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
invoiceNumber: 'INV-2025-014',
|
||||
to: {
|
||||
name: 'Bundesamt für Migration' // Public entity
|
||||
},
|
||||
metadata: {
|
||||
profileId: 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0',
|
||||
// Missing buyerReference for B2G
|
||||
}
|
||||
};
|
||||
|
||||
const results = validator.validateXRechnung(invoice as EInvoice);
|
||||
|
||||
// Should require Leitweg-ID for B2G
|
||||
const b2gErrors = results.filter(r => r.ruleId === 'XR-DE-15');
|
||||
expect(b2gErrors).toHaveLength(1);
|
||||
expect(b2gErrors[0].message).toInclude('mandatory for B2G invoices');
|
||||
});
|
||||
|
||||
tap.test('XRechnungValidator - Complete valid XRechnung invoice', async () => {
|
||||
const validator = XRechnungValidator.create();
|
||||
|
||||
const invoice: Partial<EInvoice> = {
|
||||
invoiceNumber: 'INV-2025-015',
|
||||
from: {
|
||||
type: 'company' as const,
|
||||
name: 'Example GmbH',
|
||||
registrationDetails: {
|
||||
vatId: 'DE123456789'
|
||||
}
|
||||
},
|
||||
metadata: {
|
||||
profileId: 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0',
|
||||
buyerReference: '991-12345678901-23',
|
||||
sellerTaxId: 'DE123456789',
|
||||
extensions: {
|
||||
sellerContact: {
|
||||
name: 'Sales Department',
|
||||
email: 'sales@example.de',
|
||||
phone: '+49 30 98765432'
|
||||
},
|
||||
paymentMeans: [
|
||||
{
|
||||
type: 'SEPA',
|
||||
iban: 'DE89370400440532013000',
|
||||
bic: 'COBADEFFXXX',
|
||||
accountName: 'Example GmbH'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const results = validator.validateXRechnung(invoice as EInvoice);
|
||||
|
||||
// Complete valid invoice should have no errors
|
||||
const errors = results.filter(r => r.severity === 'error');
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
export default tap.start();
|
Reference in New Issue
Block a user