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:
317
ts/formats/validation/codelist.validator.ts
Normal file
317
ts/formats/validation/codelist.validator.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import type { ValidationResult } from './validation.types.js';
|
||||
import { CodeLists } from './validation.types.js';
|
||||
import type { TAccountingDocItem } from '@tsclass/tsclass/dist_ts/finance/index.js';
|
||||
import type { EInvoice } from '../../einvoice.js';
|
||||
import type { IExtendedAccountingDocItem } from '../../interfaces/en16931-metadata.js';
|
||||
|
||||
/**
|
||||
* Code List Validator for EN16931 compliance
|
||||
* Validates against standard code lists (ISO, UNCL, UNECE)
|
||||
*/
|
||||
export class CodeListValidator {
|
||||
private results: ValidationResult[] = [];
|
||||
|
||||
/**
|
||||
* Validate all code lists in an invoice
|
||||
*/
|
||||
public validate(invoice: EInvoice): ValidationResult[] {
|
||||
this.results = [];
|
||||
|
||||
// Currency validation
|
||||
this.validateCurrency(invoice);
|
||||
|
||||
// Country codes
|
||||
this.validateCountryCodes(invoice);
|
||||
|
||||
// Document type
|
||||
this.validateDocumentType(invoice);
|
||||
|
||||
// Tax categories
|
||||
this.validateTaxCategories(invoice);
|
||||
|
||||
// Payment means
|
||||
this.validatePaymentMeans(invoice);
|
||||
|
||||
// Unit codes
|
||||
this.validateUnitCodes(invoice);
|
||||
|
||||
return this.results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate currency codes (ISO 4217)
|
||||
*/
|
||||
private validateCurrency(invoice: EInvoice): void {
|
||||
// Document currency (BT-5)
|
||||
if (invoice.currency) {
|
||||
if (!CodeLists.ISO4217.codes.has(invoice.currency.toUpperCase())) {
|
||||
this.addError(
|
||||
'BR-CL-03',
|
||||
`Invalid currency code: ${invoice.currency}. Must be ISO 4217`,
|
||||
'EN16931',
|
||||
'currency',
|
||||
'BT-5',
|
||||
invoice.currency,
|
||||
Array.from(CodeLists.ISO4217.codes).join(', ')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// VAT accounting currency (BT-6)
|
||||
const vatCurrency = invoice.metadata?.vatAccountingCurrency;
|
||||
if (vatCurrency && !CodeLists.ISO4217.codes.has(vatCurrency.toUpperCase())) {
|
||||
this.addError(
|
||||
'BR-CL-04',
|
||||
`Invalid VAT accounting currency: ${vatCurrency}. Must be ISO 4217`,
|
||||
'EN16931',
|
||||
'metadata.vatAccountingCurrency',
|
||||
'BT-6',
|
||||
vatCurrency,
|
||||
Array.from(CodeLists.ISO4217.codes).join(', ')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate country codes (ISO 3166-1 alpha-2)
|
||||
*/
|
||||
private validateCountryCodes(invoice: EInvoice): void {
|
||||
// Seller country (BT-40)
|
||||
const sellerCountry = invoice.from?.address?.countryCode;
|
||||
if (sellerCountry && !CodeLists.ISO3166.codes.has(sellerCountry.toUpperCase())) {
|
||||
this.addError(
|
||||
'BR-CL-14',
|
||||
`Invalid seller country code: ${sellerCountry}. Must be ISO 3166-1 alpha-2`,
|
||||
'EN16931',
|
||||
'from.address.countryCode',
|
||||
'BT-40',
|
||||
sellerCountry,
|
||||
'Two-letter country code (e.g., DE, FR, IT)'
|
||||
);
|
||||
}
|
||||
|
||||
// Buyer country (BT-55)
|
||||
const buyerCountry = invoice.to?.address?.countryCode;
|
||||
if (buyerCountry && !CodeLists.ISO3166.codes.has(buyerCountry.toUpperCase())) {
|
||||
this.addError(
|
||||
'BR-CL-15',
|
||||
`Invalid buyer country code: ${buyerCountry}. Must be ISO 3166-1 alpha-2`,
|
||||
'EN16931',
|
||||
'to.address.countryCode',
|
||||
'BT-55',
|
||||
buyerCountry,
|
||||
'Two-letter country code (e.g., DE, FR, IT)'
|
||||
);
|
||||
}
|
||||
|
||||
// Delivery country (BT-80)
|
||||
const deliveryCountry = invoice.metadata?.deliveryAddress?.countryCode;
|
||||
if (deliveryCountry && !CodeLists.ISO3166.codes.has(deliveryCountry.toUpperCase())) {
|
||||
this.addError(
|
||||
'BR-CL-16',
|
||||
`Invalid delivery country code: ${deliveryCountry}. Must be ISO 3166-1 alpha-2`,
|
||||
'EN16931',
|
||||
'metadata.deliveryAddress.countryCode',
|
||||
'BT-80',
|
||||
deliveryCountry,
|
||||
'Two-letter country code (e.g., DE, FR, IT)'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate document type code (UNCL1001)
|
||||
*/
|
||||
private validateDocumentType(invoice: EInvoice): void {
|
||||
const typeCode = invoice.metadata?.documentTypeCode ||
|
||||
(invoice.accountingDocType === 'invoice' ? '380' :
|
||||
invoice.accountingDocType === 'creditnote' ? '381' :
|
||||
invoice.accountingDocType === 'debitnote' ? '383' : null);
|
||||
|
||||
if (typeCode && !CodeLists.UNCL1001.codes.has(typeCode)) {
|
||||
this.addError(
|
||||
'BR-CL-01',
|
||||
`Invalid document type code: ${typeCode}. Must be UNCL1001`,
|
||||
'EN16931',
|
||||
'metadata.documentTypeCode',
|
||||
'BT-3',
|
||||
typeCode,
|
||||
Array.from(CodeLists.UNCL1001.codes.entries()).map(([k, v]) => `${k} (${v})`).join(', ')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate tax category codes (UNCL5305)
|
||||
*/
|
||||
private validateTaxCategories(invoice: EInvoice): void {
|
||||
// Document level tax breakdown
|
||||
// Note: taxBreakdown is a computed property that doesn't have metadata
|
||||
// We would need to access the raw tax breakdown data from metadata if it exists
|
||||
invoice.taxBreakdown?.forEach((breakdown, index) => {
|
||||
// Since the computed taxBreakdown doesn't have metadata,
|
||||
// we'll skip the tax category code validation for now
|
||||
// This would need to be implemented differently to access the raw data
|
||||
|
||||
// TODO: Access raw tax breakdown data with metadata from invoice.metadata.taxBreakdown
|
||||
// when that structure is implemented
|
||||
});
|
||||
|
||||
// Line level tax categories
|
||||
invoice.items?.forEach((item, index) => {
|
||||
// Cast to extended type to access metadata
|
||||
const extendedItem = item as IExtendedAccountingDocItem;
|
||||
const categoryCode = extendedItem.metadata?.vatCategoryCode;
|
||||
if (categoryCode && !CodeLists.UNCL5305.codes.has(categoryCode)) {
|
||||
this.addError(
|
||||
'BR-CL-10',
|
||||
`Invalid line tax category: ${categoryCode}. Must be UNCL5305`,
|
||||
'EN16931',
|
||||
`items[${index}].metadata.vatCategoryCode`,
|
||||
'BT-151',
|
||||
categoryCode,
|
||||
Array.from(CodeLists.UNCL5305.codes.entries()).map(([k, v]) => `${k} (${v})`).join(', ')
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate payment means codes (UNCL4461)
|
||||
*/
|
||||
private validatePaymentMeans(invoice: EInvoice): void {
|
||||
const paymentMeans = invoice.metadata?.paymentMeansCode;
|
||||
if (paymentMeans && !CodeLists.UNCL4461.codes.has(paymentMeans)) {
|
||||
this.addError(
|
||||
'BR-CL-16',
|
||||
`Invalid payment means code: ${paymentMeans}. Must be UNCL4461`,
|
||||
'EN16931',
|
||||
'metadata.paymentMeansCode',
|
||||
'BT-81',
|
||||
paymentMeans,
|
||||
Array.from(CodeLists.UNCL4461.codes.entries()).map(([k, v]) => `${k} (${v})`).join(', ')
|
||||
);
|
||||
}
|
||||
|
||||
// Validate payment requirements based on means code
|
||||
if (paymentMeans === '30' || paymentMeans === '58') { // Credit transfer
|
||||
if (!invoice.metadata?.paymentAccount?.iban) {
|
||||
this.addWarning(
|
||||
'BR-CL-16-1',
|
||||
`Payment means ${paymentMeans} (${CodeLists.UNCL4461.codes.get(paymentMeans)}) typically requires IBAN`,
|
||||
'EN16931',
|
||||
'metadata.paymentAccount.iban',
|
||||
'BT-84'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate unit codes (UNECE Rec 20)
|
||||
*/
|
||||
private validateUnitCodes(invoice: EInvoice): void {
|
||||
invoice.items?.forEach((item, index) => {
|
||||
const unitCode = item.unitType;
|
||||
if (unitCode && !CodeLists.UNECERec20.codes.has(unitCode)) {
|
||||
this.addError(
|
||||
'BR-CL-23',
|
||||
`Invalid unit code: ${unitCode}. Must be UNECE Rec 20`,
|
||||
'EN16931',
|
||||
`items[${index}].unitCode`,
|
||||
'BT-130',
|
||||
unitCode,
|
||||
'Common codes: C62 (one), KGM (kilogram), HUR (hour), DAY (day), MTR (metre)'
|
||||
);
|
||||
}
|
||||
|
||||
// Validate quantity is positive for standard units
|
||||
if (unitCode && item.unitQuantity <= 0 && unitCode !== 'LS') { // LS = Lump sum can be 1
|
||||
this.addError(
|
||||
'BR-25',
|
||||
`Quantity must be positive for unit ${unitCode}`,
|
||||
'EN16931',
|
||||
`items[${index}].quantity`,
|
||||
'BT-129',
|
||||
item.unitQuantity,
|
||||
'> 0'
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add validation error
|
||||
*/
|
||||
private addError(
|
||||
ruleId: string,
|
||||
message: string,
|
||||
source: string,
|
||||
field: string,
|
||||
btReference?: string,
|
||||
value?: any,
|
||||
expected?: any
|
||||
): void {
|
||||
this.results.push({
|
||||
ruleId,
|
||||
source,
|
||||
severity: 'error',
|
||||
message,
|
||||
field,
|
||||
btReference,
|
||||
value,
|
||||
expected,
|
||||
codeList: this.getCodeListForRule(ruleId)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add validation warning
|
||||
*/
|
||||
private addWarning(
|
||||
ruleId: string,
|
||||
message: string,
|
||||
source: string,
|
||||
field: string,
|
||||
btReference?: string,
|
||||
value?: any,
|
||||
expected?: any
|
||||
): void {
|
||||
this.results.push({
|
||||
ruleId,
|
||||
source,
|
||||
severity: 'warning',
|
||||
message,
|
||||
field,
|
||||
btReference,
|
||||
value,
|
||||
expected,
|
||||
codeList: this.getCodeListForRule(ruleId)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get code list metadata for a rule
|
||||
*/
|
||||
private getCodeListForRule(ruleId: string): { name: string; version: string } | undefined {
|
||||
if (ruleId.includes('CL-03') || ruleId.includes('CL-04')) {
|
||||
return { name: 'ISO4217', version: CodeLists.ISO4217.version };
|
||||
}
|
||||
if (ruleId.includes('CL-14') || ruleId.includes('CL-15') || ruleId.includes('CL-16')) {
|
||||
return { name: 'ISO3166', version: CodeLists.ISO3166.version };
|
||||
}
|
||||
if (ruleId.includes('CL-01')) {
|
||||
return { name: 'UNCL1001', version: CodeLists.UNCL1001.version };
|
||||
}
|
||||
if (ruleId.includes('CL-10')) {
|
||||
return { name: 'UNCL5305', version: CodeLists.UNCL5305.version };
|
||||
}
|
||||
if (ruleId.includes('CL-16')) {
|
||||
return { name: 'UNCL4461', version: CodeLists.UNCL4461.version };
|
||||
}
|
||||
if (ruleId.includes('CL-23')) {
|
||||
return { name: 'UNECERec20', version: CodeLists.UNECERec20.version };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user