- 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.
317 lines
9.5 KiB
TypeScript
317 lines
9.5 KiB
TypeScript
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;
|
|
}
|
|
} |