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:
2025-08-11 18:07:01 +00:00
parent 10e14af85b
commit cbb297b0b1
24 changed files with 7714 additions and 98 deletions

View File

@@ -25,29 +25,45 @@ export class XMLToEInvoiceConverter {
public async convert(xmlContent: string, format: 'UBL' | 'CII'): Promise<EInvoice> {
// For now, return a mock invoice for testing
// A full implementation would parse the XML and extract all fields
const mockInvoice: EInvoice = {
const mockInvoice = {
accountingDocId: 'TEST-001',
accountingDocType: 'invoice',
date: Date.now(),
items: [],
from: {
type: 'company' as const,
name: 'Test Seller',
description: 'Test Seller Company',
address: {
streetAddress: 'Test Street',
streetName: 'Test Street',
houseNumber: '1',
city: 'Test City',
postalCode: '12345',
country: 'Germany',
countryCode: 'DE'
},
registrationDetails: {
companyName: 'Test Seller Company',
registrationCountry: 'DE'
}
},
} as any,
to: {
type: 'company' as const,
name: 'Test Buyer',
description: 'Test Buyer Company',
address: {
streetAddress: 'Test Street',
streetName: 'Test Street',
houseNumber: '2',
city: 'Test City',
postalCode: '12345',
country: 'Germany',
countryCode: 'DE'
},
registrationDetails: {
companyName: 'Test Buyer Company',
registrationCountry: 'DE'
}
},
} as any,
currency: 'EUR' as any,
get totalNet() { return 100; },
get totalGross() { return 119; },
@@ -100,7 +116,7 @@ export class XMLToEInvoiceConverter {
console.warn('Error parsing XML:', error);
}
return mockInvoice;
return mockInvoice as EInvoice;
}
/**

View File

@@ -0,0 +1,524 @@
/**
* EN16931 Canonical Semantic Model
* Defines all Business Terms (BT) and Business Groups (BG) from the standard
* This provides a format-agnostic representation of invoice data
*/
/**
* Business Term (BT) definitions from EN16931
* Each BT represents a specific data element in an invoice
*/
export interface BusinessTerms {
// Document level information (BT-1 to BT-22)
BT1_InvoiceNumber: string;
BT2_InvoiceIssueDate: Date;
BT3_InvoiceTypeCode: string;
BT4_InvoiceNote?: string;
BT5_InvoiceCurrencyCode: string;
BT6_VATAccountingCurrencyCode?: string;
BT7_ValueDateForVATCalculation?: Date;
BT8_InvoicePeriodDescriptionCode?: string;
BT9_DueDate?: Date;
BT10_BuyerReference?: string;
BT11_ProjectReference?: string;
BT12_ContractReference?: string;
BT13_PurchaseOrderReference?: string;
BT14_SalesOrderReference?: string;
BT15_ReceivingAdviceReference?: string;
BT16_DespatchAdviceReference?: string;
BT17_TenderOrLotReference?: string;
BT18_InvoicedObjectIdentifier?: string;
BT19_BuyerAccountingReference?: string;
BT20_PaymentTerms?: string;
BT21_InvoiceNote?: string[];
BT22_ProcessSpecificNote?: string;
// Seller information (BT-23 to BT-40)
BT23_BusinessProcessType?: string;
BT24_SpecificationIdentifier?: string;
BT25_InvoiceAttachment?: Attachment[];
BT26_InvoiceDocumentReference?: string;
BT27_SellerName: string;
BT28_SellerTradingName?: string;
BT29_SellerIdentifier?: string;
BT30_SellerLegalRegistrationIdentifier?: string;
BT31_SellerVATIdentifier?: string;
BT32_SellerTaxRegistrationIdentifier?: string;
BT33_SellerAdditionalLegalInfo?: string;
BT34_SellerElectronicAddress?: string;
BT35_SellerAddressLine1?: string;
BT36_SellerAddressLine2?: string;
BT37_SellerAddressLine3?: string;
BT38_SellerCity?: string;
BT39_SellerPostCode?: string;
BT40_SellerCountryCode: string;
// Seller contact (BT-41 to BT-43)
BT41_SellerContactPoint?: string;
BT42_SellerContactTelephoneNumber?: string;
BT43_SellerContactEmailAddress?: string;
// Buyer information (BT-44 to BT-58)
BT44_BuyerName: string;
BT45_BuyerTradingName?: string;
BT46_BuyerIdentifier?: string;
BT47_BuyerLegalRegistrationIdentifier?: string;
BT48_BuyerVATIdentifier?: string;
BT49_BuyerElectronicAddress?: string;
BT50_BuyerAddressLine1?: string;
BT51_BuyerAddressLine2?: string;
BT52_BuyerAddressLine3?: string;
BT53_BuyerCity?: string;
BT54_BuyerPostCode?: string;
BT55_BuyerCountryCode: string;
BT56_BuyerContactPoint?: string;
BT57_BuyerContactTelephoneNumber?: string;
BT58_BuyerContactEmailAddress?: string;
// Payee information (BT-59 to BT-62)
BT59_PayeeName?: string;
BT60_PayeeIdentifier?: string;
BT61_PayeeLegalRegistrationIdentifier?: string;
BT62_PayeeLegalRegistrationIdentifierSchemeID?: string;
// Tax representative (BT-62 to BT-69)
BT63_SellerTaxRepresentativeName?: string;
BT64_SellerTaxRepresentativeVATIdentifier?: string;
BT65_SellerTaxRepresentativeAddressLine1?: string;
BT66_SellerTaxRepresentativeAddressLine2?: string;
BT67_SellerTaxRepresentativeCity?: string;
BT68_SellerTaxRepresentativePostCode?: string;
BT69_SellerTaxRepresentativeCountryCode?: string;
// Delivery information (BT-70 to BT-80)
BT70_DeliveryName?: string;
BT71_DeliveryLocationIdentifier?: string;
BT72_ActualDeliveryDate?: Date;
BT73_InvoicingPeriodStartDate?: Date;
BT74_InvoicingPeriodEndDate?: Date;
BT75_DeliveryAddressLine1?: string;
BT76_DeliveryAddressLine2?: string;
BT77_DeliveryAddressLine3?: string;
BT78_DeliveryCity?: string;
BT79_DeliveryPostCode?: string;
BT80_DeliveryCountryCode?: string;
// Payment instructions (BT-81 to BT-91)
BT81_PaymentMeansTypeCode: string;
BT82_PaymentMeansText?: string;
BT83_RemittanceInformation?: string;
BT84_PaymentAccountIdentifier?: string;
BT85_PaymentAccountName?: string;
BT86_PaymentServiceProviderIdentifier?: string;
BT87_PaymentCardAccountPrimaryNumber?: string;
BT88_PaymentCardAccountHolderName?: string;
BT89_MandateReferenceIdentifier?: string;
BT90_BankAssignedCreditorIdentifier?: string;
BT91_DebitedAccountIdentifier?: string;
// Document level allowances (BT-92 to BT-96)
BT92_DocumentLevelAllowanceAmount?: number;
BT93_DocumentLevelAllowanceBaseAmount?: number;
BT94_DocumentLevelAllowancePercentage?: number;
BT95_DocumentLevelAllowanceVATCategoryCode?: string;
BT96_DocumentLevelAllowanceVATRate?: number;
BT97_DocumentLevelAllowanceReason?: string;
BT98_DocumentLevelAllowanceReasonCode?: string;
// Document level charges (BT-99 to BT-105)
BT99_DocumentLevelChargeAmount?: number;
BT100_DocumentLevelChargeBaseAmount?: number;
BT101_DocumentLevelChargePercentage?: number;
BT102_DocumentLevelChargeVATCategoryCode?: string;
BT103_DocumentLevelChargeVATRate?: number;
BT104_DocumentLevelChargeReason?: string;
BT105_DocumentLevelChargeReasonCode?: string;
// Document totals (BT-106 to BT-115)
BT106_SumOfInvoiceLineNetAmount: number;
BT107_SumOfAllowancesOnDocumentLevel?: number;
BT108_SumOfChargesOnDocumentLevel?: number;
BT109_InvoiceTotalAmountWithoutVAT: number;
BT110_InvoiceTotalVATAmount?: number;
BT111_InvoiceTotalVATAmountInAccountingCurrency?: number;
BT112_InvoiceTotalAmountWithVAT: number;
BT113_PaidAmount?: number;
BT114_RoundingAmount?: number;
BT115_AmountDueForPayment: number;
// VAT breakdown (BT-116 to BT-121)
BT116_VATCategoryTaxableAmount?: number;
BT117_VATCategoryTaxAmount?: number;
BT118_VATCategoryCode?: string;
BT119_VATCategoryRate?: number;
BT120_VATExemptionReasonText?: string;
BT121_VATExemptionReasonCode?: string;
// Additional document references (BT-122 to BT-125)
BT122_SupportingDocumentReference?: string;
BT123_SupportingDocumentDescription?: string;
BT124_ExternalDocumentLocation?: string;
BT125_AttachedDocumentEmbedded?: string;
// Line level information (BT-126 to BT-162)
BT126_InvoiceLineIdentifier?: string;
BT127_InvoiceLineNote?: string;
BT128_InvoiceLineObjectIdentifier?: string;
BT129_InvoicedQuantity?: number;
BT130_InvoicedQuantityUnitOfMeasureCode?: string;
BT131_InvoiceLineNetAmount?: number;
BT132_ReferencedPurchaseOrderLineReference?: string;
BT133_InvoiceLineBuyerAccountingReference?: string;
BT134_InvoiceLinePeriodStartDate?: Date;
BT135_InvoiceLinePeriodEndDate?: Date;
BT136_InvoiceLineAllowanceAmount?: number;
BT137_InvoiceLineAllowanceBaseAmount?: number;
BT138_InvoiceLineAllowancePercentage?: number;
BT139_InvoiceLineAllowanceReason?: string;
BT140_InvoiceLineAllowanceReasonCode?: string;
BT141_InvoiceLineChargeAmount?: number;
BT142_InvoiceLineChargeBaseAmount?: number;
BT143_InvoiceLineChargePercentage?: number;
BT144_InvoiceLineChargeReason?: string;
BT145_InvoiceLineChargeReasonCode?: string;
BT146_ItemNetPrice?: number;
BT147_ItemPriceDiscount?: number;
BT148_ItemGrossPrice?: number;
BT149_ItemPriceBaseQuantity?: number;
BT150_ItemPriceBaseQuantityUnitOfMeasureCode?: string;
BT151_ItemVATCategoryCode?: string;
BT152_ItemVATRate?: number;
BT153_ItemName?: string;
BT154_ItemDescription?: string;
BT155_ItemSellersIdentifier?: string;
BT156_ItemBuyersIdentifier?: string;
BT157_ItemStandardIdentifier?: string;
BT158_ItemClassificationIdentifier?: string;
BT159_ItemClassificationListIdentifier?: string;
BT160_ItemOriginCountryCode?: string;
BT161_ItemAttributeName?: string;
BT162_ItemAttributeValue?: string;
}
/**
* Business Groups (BG) from EN16931
* Groups related business terms together
*/
export interface BusinessGroups {
BG1_InvoiceNote?: InvoiceNote;
BG2_ProcessControl?: ProcessControl;
BG3_PrecedingInvoiceReference?: PrecedingInvoiceReference[];
BG4_Seller: Seller;
BG5_SellerPostalAddress: PostalAddress;
BG6_SellerContact?: Contact;
BG7_Buyer: Buyer;
BG8_BuyerPostalAddress: PostalAddress;
BG9_BuyerContact?: Contact;
BG10_Payee?: Payee;
BG11_SellerTaxRepresentative?: TaxRepresentative;
BG12_PayerParty?: PayerParty;
BG13_DeliveryInformation?: DeliveryInformation;
BG14_InvoicingPeriod?: Period;
BG15_DeliverToAddress?: PostalAddress;
BG16_PaymentInstructions: PaymentInstructions;
BG17_PaymentCardInformation?: PaymentCardInformation;
BG18_DirectDebit?: DirectDebit;
BG19_PaymentTerms?: PaymentTerms;
BG20_DocumentLevelAllowances?: Allowance[];
BG21_DocumentLevelCharges?: Charge[];
BG22_DocumentTotals: DocumentTotals;
BG23_VATBreakdown?: VATBreakdown[];
BG24_AdditionalSupportingDocuments?: SupportingDocument[];
BG25_InvoiceLine: InvoiceLine[];
BG26_InvoiceLinePeriod?: Period;
BG27_InvoiceLineAllowances?: Allowance[];
BG28_InvoiceLineCharges?: Charge[];
BG29_PriceDetails?: PriceDetails;
BG30_LineVATInformation: VATInformation;
BG31_ItemInformation: ItemInformation;
BG32_ItemAttributes?: ItemAttribute[];
}
/**
* Supporting types for Business Groups
*/
export interface InvoiceNote {
subjectCode?: string;
noteContent: string;
}
export interface ProcessControl {
businessProcessType?: string;
specificationIdentifier: string;
}
export interface PrecedingInvoiceReference {
referenceNumber: string;
issueDate?: Date;
}
export interface Seller {
name: string;
tradingName?: string;
identifier?: string;
legalRegistrationIdentifier?: string;
vatIdentifier?: string;
taxRegistrationIdentifier?: string;
additionalLegalInfo?: string;
electronicAddress?: string;
}
export interface Buyer {
name: string;
tradingName?: string;
identifier?: string;
legalRegistrationIdentifier?: string;
vatIdentifier?: string;
electronicAddress?: string;
}
export interface PostalAddress {
addressLine1?: string;
addressLine2?: string;
addressLine3?: string;
city?: string;
postCode?: string;
countrySubdivision?: string;
countryCode: string;
}
export interface Contact {
contactPoint?: string;
telephoneNumber?: string;
emailAddress?: string;
}
export interface Payee {
name: string;
identifier?: string;
legalRegistrationIdentifier?: string;
}
export interface TaxRepresentative {
name: string;
vatIdentifier: string;
postalAddress: PostalAddress;
}
export interface PayerParty {
name: string;
identifier?: string;
legalRegistrationIdentifier?: string;
}
export interface DeliveryInformation {
name?: string;
locationIdentifier?: string;
actualDeliveryDate?: Date;
deliveryAddress?: PostalAddress;
}
export interface Period {
startDate?: Date;
endDate?: Date;
descriptionCode?: string;
}
export interface PaymentInstructions {
paymentMeansTypeCode: string;
paymentMeansText?: string;
remittanceInformation?: string;
paymentAccountIdentifier?: string;
paymentAccountName?: string;
paymentServiceProviderIdentifier?: string;
}
export interface PaymentCardInformation {
primaryAccountNumber: string;
holderName?: string;
}
export interface DirectDebit {
mandateReferenceIdentifier?: string;
bankAssignedCreditorIdentifier?: string;
debitedAccountIdentifier?: string;
}
export interface PaymentTerms {
note?: string;
}
export interface Allowance {
amount: number;
baseAmount?: number;
percentage?: number;
vatCategoryCode?: string;
vatRate?: number;
reason?: string;
reasonCode?: string;
}
export interface Charge {
amount: number;
baseAmount?: number;
percentage?: number;
vatCategoryCode?: string;
vatRate?: number;
reason?: string;
reasonCode?: string;
}
export interface DocumentTotals {
lineExtensionAmount: number;
taxExclusiveAmount: number;
taxInclusiveAmount: number;
allowanceTotalAmount?: number;
chargeTotalAmount?: number;
prepaidAmount?: number;
roundingAmount?: number;
payableAmount: number;
}
export interface VATBreakdown {
vatCategoryTaxableAmount: number;
vatCategoryTaxAmount: number;
vatCategoryCode: string;
vatCategoryRate?: number;
vatExemptionReasonText?: string;
vatExemptionReasonCode?: string;
}
export interface SupportingDocument {
documentReference: string;
documentDescription?: string;
externalDocumentLocation?: string;
attachedDocument?: Attachment;
}
export interface Attachment {
filename?: string;
mimeType?: string;
description?: string;
embeddedDocumentBinaryObject?: string;
externalDocumentURI?: string;
}
export interface InvoiceLine {
identifier: string;
note?: string;
objectIdentifier?: string;
invoicedQuantity: number;
invoicedQuantityUnitOfMeasureCode: string;
lineExtensionAmount: number;
purchaseOrderLineReference?: string;
buyerAccountingReference?: string;
period?: Period;
allowances?: Allowance[];
charges?: Charge[];
priceDetails: PriceDetails;
vatInformation: VATInformation;
itemInformation: ItemInformation;
}
export interface PriceDetails {
itemNetPrice: number;
itemPriceDiscount?: number;
itemGrossPrice?: number;
itemPriceBaseQuantity?: number;
itemPriceBaseQuantityUnitOfMeasureCode?: string;
}
export interface VATInformation {
categoryCode: string;
rate?: number;
}
export interface ItemInformation {
name: string;
description?: string;
sellersIdentifier?: string;
buyersIdentifier?: string;
standardIdentifier?: string;
classificationIdentifier?: string;
classificationListIdentifier?: string;
originCountryCode?: string;
attributes?: ItemAttribute[];
}
export interface ItemAttribute {
name: string;
value: string;
}
/**
* Complete EN16931 Semantic Model
* Combines all Business Terms and Business Groups
*/
export interface EN16931SemanticModel {
// Core document information
documentInformation: {
invoiceNumber: string; // BT-1
issueDate: Date; // BT-2
typeCode: string; // BT-3
currencyCode: string; // BT-5
notes?: InvoiceNote[]; // BG-1
};
// Process metadata
processControl?: ProcessControl; // BG-2
// References
references?: {
buyerReference?: string; // BT-10
projectReference?: string; // BT-11
contractReference?: string; // BT-12
purchaseOrderReference?: string; // BT-13
salesOrderReference?: string; // BT-14
precedingInvoices?: PrecedingInvoiceReference[]; // BG-3
};
// Parties
seller: Seller & { // BG-4
postalAddress: PostalAddress; // BG-5
contact?: Contact; // BG-6
};
buyer: Buyer & { // BG-7
postalAddress: PostalAddress; // BG-8
contact?: Contact; // BG-9
};
payee?: Payee; // BG-10
taxRepresentative?: TaxRepresentative; // BG-11
// Delivery
delivery?: DeliveryInformation; // BG-13
invoicingPeriod?: Period; // BG-14
// Payment
paymentInstructions: PaymentInstructions; // BG-16
paymentCardInfo?: PaymentCardInformation; // BG-17
directDebit?: DirectDebit; // BG-18
paymentTerms?: PaymentTerms; // BG-19
// Allowances and charges
documentLevelAllowances?: Allowance[]; // BG-20
documentLevelCharges?: Charge[]; // BG-21
// Totals
documentTotals: DocumentTotals; // BG-22
vatBreakdown?: VATBreakdown[]; // BG-23
// Supporting documents
additionalDocuments?: SupportingDocument[]; // BG-24
// Invoice lines
invoiceLines: InvoiceLine[]; // BG-25
}
/**
* Semantic model version and metadata
*/
export const SEMANTIC_MODEL_VERSION = '1.3.0';
export const EN16931_VERSION = '1.3.14';
export const SUPPORTED_SYNTAXES = ['UBL', 'CII', 'EDIFACT'];

View File

@@ -0,0 +1,596 @@
/**
* Adapter for converting between EInvoice and EN16931 Semantic Model
* Provides bidirectional conversion capabilities
*/
import { EInvoice } from '../../einvoice.js';
import type {
EN16931SemanticModel,
Seller,
Buyer,
PostalAddress,
Contact,
InvoiceLine,
VATBreakdown,
DocumentTotals,
PaymentInstructions,
Allowance,
Charge,
Period,
DeliveryInformation,
PriceDetails,
VATInformation,
ItemInformation
} from './bt-bg.model.js';
/**
* Adapter for converting between EInvoice and EN16931 Semantic Model
*/
export class SemanticModelAdapter {
/**
* Convert EInvoice to EN16931 Semantic Model
*/
public toSemanticModel(invoice: EInvoice): EN16931SemanticModel {
return {
// Core document information
documentInformation: {
invoiceNumber: invoice.accountingDocId,
issueDate: invoice.issueDate,
typeCode: this.mapInvoiceType(invoice.accountingDocType),
currencyCode: invoice.currency,
notes: invoice.notes ? this.mapNotes(invoice.notes) : undefined
},
// Process metadata
processControl: invoice.metadata?.profileId ? {
businessProcessType: invoice.metadata.businessProcessId,
specificationIdentifier: invoice.metadata.profileId
} : undefined,
// References
references: {
buyerReference: invoice.metadata?.buyerReference,
projectReference: invoice.projectReference,
contractReference: invoice.metadata?.contractReference,
purchaseOrderReference: invoice.metadata?.extensions?.purchaseOrderReference,
salesOrderReference: invoice.metadata?.extensions?.salesOrderReference,
precedingInvoices: invoice.metadata?.extensions?.precedingInvoices
},
// Seller
seller: {
...this.mapSeller(invoice.from),
postalAddress: this.mapAddress(invoice.from),
contact: this.mapContact(invoice.from)
},
// Buyer
buyer: {
...this.mapBuyer(invoice.to),
postalAddress: this.mapAddress(invoice.to),
contact: this.mapContact(invoice.to)
},
// Payee (if different from seller)
payee: invoice.metadata?.extensions?.payee,
// Tax representative
taxRepresentative: invoice.metadata?.extensions?.taxRepresentative,
// Delivery
delivery: this.mapDelivery(invoice),
// Invoice period
invoicingPeriod: invoice.metadata?.invoicingPeriod ? {
startDate: invoice.metadata.invoicingPeriod.startDate,
endDate: invoice.metadata.invoicingPeriod.endDate,
descriptionCode: invoice.metadata.invoicingPeriod.descriptionCode
} : undefined,
// Payment instructions
paymentInstructions: this.mapPaymentInstructions(invoice),
// Payment card info
paymentCardInfo: invoice.metadata?.extensions?.paymentCard,
// Direct debit
directDebit: invoice.metadata?.extensions?.directDebit,
// Payment terms
paymentTerms: invoice.dueInDays !== undefined ? {
note: `Payment due in ${invoice.dueInDays} days`
} : undefined,
// Document level allowances and charges
documentLevelAllowances: invoice.metadata?.extensions?.documentAllowances,
documentLevelCharges: invoice.metadata?.extensions?.documentCharges,
// Document totals
documentTotals: this.mapDocumentTotals(invoice),
// VAT breakdown
vatBreakdown: this.mapVATBreakdown(invoice),
// Additional documents
additionalDocuments: invoice.metadata?.extensions?.supportingDocuments,
// Invoice lines
invoiceLines: this.mapInvoiceLines(invoice.items || [])
};
}
/**
* Convert EN16931 Semantic Model to EInvoice
*/
public fromSemanticModel(model: EN16931SemanticModel): EInvoice {
const invoice = new EInvoice();
invoice.accountingDocId = model.documentInformation.invoiceNumber;
invoice.issueDate = model.documentInformation.issueDate;
invoice.accountingDocType = this.reverseMapInvoiceType(model.documentInformation.typeCode);
invoice.currency = model.documentInformation.currencyCode;
invoice.from = this.reverseMapSeller(model.seller);
invoice.to = this.reverseMapBuyer(model.buyer);
invoice.items = this.reverseMapInvoiceLines(model.invoiceLines);
// Set metadata
if (model.processControl) {
invoice.metadata = {
...invoice.metadata,
profileId: model.processControl.specificationIdentifier,
businessProcessId: model.processControl.businessProcessType
};
}
// Set references
if (model.references) {
invoice.metadata = {
...invoice.metadata,
buyerReference: model.references.buyerReference,
contractReference: model.references.contractReference,
extensions: {
...invoice.metadata?.extensions,
purchaseOrderReference: model.references.purchaseOrderReference,
salesOrderReference: model.references.salesOrderReference,
precedingInvoices: model.references.precedingInvoices
}
};
invoice.projectReference = model.references.projectReference;
}
// Set payment terms
if (model.paymentTerms?.note) {
const daysMatch = model.paymentTerms.note.match(/(\d+) days/);
if (daysMatch) {
invoice.dueInDays = parseInt(daysMatch[1], 10);
}
}
// Set payment options
if (model.paymentInstructions.paymentAccountIdentifier) {
invoice.paymentOptions = {
sepa: {
iban: model.paymentInstructions.paymentAccountIdentifier,
bic: model.paymentInstructions.paymentServiceProviderIdentifier
},
bankInfo: {
accountHolder: model.paymentInstructions.paymentAccountName || '',
institutionName: model.paymentInstructions.paymentServiceProviderIdentifier || ''
}
} as any;
}
// Set extensions
if (model.payee || model.taxRepresentative || model.documentLevelAllowances) {
invoice.metadata = {
...invoice.metadata,
extensions: {
...invoice.metadata?.extensions,
payee: model.payee,
taxRepresentative: model.taxRepresentative,
documentAllowances: model.documentLevelAllowances,
documentCharges: model.documentLevelCharges,
supportingDocuments: model.additionalDocuments,
paymentCard: model.paymentCardInfo,
directDebit: model.directDebit,
taxDetails: model.vatBreakdown
}
};
}
return invoice;
}
/**
* Map invoice type code
*/
private mapInvoiceType(type: string): string {
const typeMap: Record<string, string> = {
'invoice': '380',
'creditNote': '381',
'debitNote': '383',
'correctedInvoice': '384',
'prepaymentInvoice': '386',
'selfBilledInvoice': '389',
'invoice_380': '380',
'credit_note_381': '381'
};
return typeMap[type] || '380';
}
/**
* Reverse map invoice type code
*/
private reverseMapInvoiceType(code: string): string {
const typeMap: Record<string, string> = {
'380': 'invoice',
'381': 'creditNote',
'383': 'debitNote',
'384': 'correctedInvoice',
'386': 'prepaymentInvoice',
'389': 'selfBilledInvoice'
};
return typeMap[code] || 'invoice';
}
/**
* Map notes
*/
private mapNotes(notes: string | string[]): Array<{ noteContent: string }> {
const notesArray = Array.isArray(notes) ? notes : [notes];
return notesArray.map(note => ({ noteContent: note }));
}
/**
* Map seller information
*/
private mapSeller(from: EInvoice['from']): Seller {
const contact = from as any;
if (contact.type === 'company') {
return {
name: contact.name || '',
tradingName: contact.tradingName,
identifier: contact.registrationDetails?.registrationId,
legalRegistrationIdentifier: contact.registrationDetails?.registrationId,
vatIdentifier: contact.registrationDetails?.vatId || contact.vatNumber,
taxRegistrationIdentifier: contact.taxId,
additionalLegalInfo: contact.description,
electronicAddress: contact.email || contact.contact?.email
};
} else {
return {
name: contact.name || `${contact.firstName || ''} ${contact.lastName || ''}`.trim(),
identifier: contact.registrationDetails?.registrationId,
vatIdentifier: contact.registrationDetails?.vatId || contact.vatNumber,
electronicAddress: contact.email
};
}
}
/**
* Map buyer information
*/
private mapBuyer(to: EInvoice['to']): Buyer {
const contact = to as any;
if (contact.type === 'company') {
return {
name: contact.name || '',
tradingName: contact.tradingName,
identifier: contact.registrationDetails?.registrationId,
legalRegistrationIdentifier: contact.registrationDetails?.registrationId,
vatIdentifier: contact.registrationDetails?.vatId || contact.vatNumber,
electronicAddress: contact.email || contact.contact?.email
};
} else {
return {
name: contact.name || `${contact.firstName || ''} ${contact.lastName || ''}`.trim(),
identifier: contact.registrationDetails?.registrationId,
vatIdentifier: contact.registrationDetails?.vatId || contact.vatNumber,
electronicAddress: contact.email
};
}
}
/**
* Map address
*/
private mapAddress(party: EInvoice['from'] | EInvoice['to']): PostalAddress {
const contact = party as any;
const address: PostalAddress = {
countryCode: contact.address?.country || contact.country || ''
};
if (contact.address) {
if (typeof contact.address === 'string') {
const addressParts = contact.address.split(',').map((s: string) => s.trim());
address.addressLine1 = addressParts[0];
if (addressParts.length > 1) address.addressLine2 = addressParts[1];
} else if (typeof contact.address === 'object') {
address.addressLine1 = [contact.address.streetName, contact.address.houseNumber].filter(Boolean).join(' ');
address.city = contact.address.city;
address.postCode = contact.address.postalCode;
address.countryCode = contact.address.country || address.countryCode;
}
}
// Support both nested and flat structures
if (!address.city) address.city = contact.city;
if (!address.postCode) address.postCode = contact.postalCode;
return address;
}
/**
* Map contact information
*/
private mapContact(party: EInvoice['from'] | EInvoice['to']): Contact | undefined {
const contact = party as any;
if (contact.type === 'company' && contact.contact) {
return {
contactPoint: contact.contact.name,
telephoneNumber: contact.contact.phone,
emailAddress: contact.contact.email
};
} else if (contact.type === 'person') {
return {
contactPoint: contact.name || `${contact.firstName || ''} ${contact.lastName || ''}`.trim(),
telephoneNumber: contact.phone,
emailAddress: contact.email
};
} else if (contact.email || contact.phone) {
// Fallback for any contact with email or phone
return {
contactPoint: contact.name,
telephoneNumber: contact.phone,
emailAddress: contact.email
};
}
return undefined;
}
/**
* Map delivery information
*/
private mapDelivery(invoice: EInvoice): DeliveryInformation | undefined {
const delivery = invoice.metadata?.extensions?.delivery;
if (!delivery) return undefined;
return {
name: delivery.name,
locationIdentifier: delivery.locationId,
actualDeliveryDate: delivery.actualDate,
deliveryAddress: delivery.address ? {
addressLine1: delivery.address.line1,
addressLine2: delivery.address.line2,
city: delivery.address.city,
postCode: delivery.address.postCode,
countryCode: delivery.address.countryCode
} : undefined
};
}
/**
* Map payment instructions
*/
private mapPaymentInstructions(invoice: EInvoice): PaymentInstructions {
const paymentMeans = invoice.metadata?.extensions?.paymentMeans;
return {
paymentMeansTypeCode: paymentMeans?.paymentMeansCode || '30', // Default to credit transfer
paymentMeansText: paymentMeans?.paymentMeansText,
remittanceInformation: paymentMeans?.remittanceInformation,
paymentAccountIdentifier: invoice.paymentAccount?.iban,
paymentAccountName: invoice.paymentAccount?.accountName,
paymentServiceProviderIdentifier: invoice.paymentAccount?.bic || invoice.paymentAccount?.institutionName
};
}
/**
* Map document totals
*/
private mapDocumentTotals(invoice: EInvoice): DocumentTotals {
return {
lineExtensionAmount: invoice.totalNet,
taxExclusiveAmount: invoice.totalNet,
taxInclusiveAmount: invoice.totalGross,
allowanceTotalAmount: invoice.metadata?.extensions?.documentAllowances?.reduce(
(sum, a) => sum + a.amount, 0
),
chargeTotalAmount: invoice.metadata?.extensions?.documentCharges?.reduce(
(sum, c) => sum + c.amount, 0
),
prepaidAmount: invoice.metadata?.extensions?.prepaidAmount,
roundingAmount: invoice.metadata?.extensions?.roundingAmount,
payableAmount: invoice.totalGross
};
}
/**
* Map VAT breakdown
*/
private mapVATBreakdown(invoice: EInvoice): VATBreakdown[] | undefined {
const taxDetails = invoice.metadata?.extensions?.taxDetails;
if (!taxDetails) {
// Create default VAT breakdown from invoice totals
if (invoice.totalVat > 0) {
return [{
vatCategoryTaxableAmount: invoice.totalNet,
vatCategoryTaxAmount: invoice.totalVat,
vatCategoryCode: 'S', // Standard rate
vatCategoryRate: (invoice.totalVat / invoice.totalNet) * 100
}];
}
return undefined;
}
return taxDetails as VATBreakdown[];
}
/**
* Map invoice lines
*/
private mapInvoiceLines(items: EInvoice['items']): InvoiceLine[] {
if (!items) return [];
return items.map((item, index) => ({
identifier: (index + 1).toString(),
note: (item as any).description || (item as any).text || '',
invoicedQuantity: item.unitQuantity,
invoicedQuantityUnitOfMeasureCode: item.unitType || 'C62',
lineExtensionAmount: item.unitNetPrice * item.unitQuantity,
purchaseOrderLineReference: (item as any).purchaseOrderLineRef,
buyerAccountingReference: (item as any).buyerAccountingRef,
period: (item as any).period,
allowances: (item as any).allowances,
charges: (item as any).charges,
priceDetails: {
itemNetPrice: item.unitNetPrice,
itemPriceDiscount: (item as any).priceDiscount,
itemGrossPrice: (item as any).grossPrice,
itemPriceBaseQuantity: (item as any).priceBaseQuantity || 1
},
vatInformation: {
categoryCode: this.mapVATCategory(item.vatPercentage),
rate: item.vatPercentage
},
itemInformation: {
name: item.name,
description: (item as any).description || (item as any).text || '',
sellersIdentifier: item.articleNumber,
buyersIdentifier: (item as any).buyersItemId,
standardIdentifier: (item as any).gtin || (item as any).ean,
classificationIdentifier: (item as any).unspsc,
originCountryCode: (item as any).originCountry,
attributes: (item as any).attributes
}
}));
}
/**
* Map VAT category from percentage
*/
private mapVATCategory(percentage?: number): string {
if (percentage === undefined || percentage === null) return 'S';
if (percentage === 0) return 'Z';
if (percentage > 0) return 'S';
return 'E'; // Exempt
}
/**
* Reverse map seller
*/
private reverseMapSeller(seller: Seller & { postalAddress: PostalAddress }): EInvoice['from'] {
const isCompany = seller.legalRegistrationIdentifier || seller.tradingName;
return {
type: isCompany ? 'company' : 'person',
name: seller.name,
description: seller.additionalLegalInfo || '',
address: {
streetName: seller.postalAddress.addressLine1 || '',
houseNumber: '',
city: seller.postalAddress.city || '',
postalCode: seller.postalAddress.postCode || '',
country: seller.postalAddress.countryCode || ''
},
registrationDetails: {
vatId: seller.vatIdentifier || '',
registrationId: seller.identifier || seller.legalRegistrationIdentifier || '',
registrationName: seller.name
},
status: 'active',
foundedDate: {
year: new Date().getFullYear(),
month: new Date().getMonth() + 1,
day: new Date().getDate()
}
} as any;
}
/**
* Reverse map buyer
*/
private reverseMapBuyer(buyer: Buyer & { postalAddress: PostalAddress }): EInvoice['to'] {
const isCompany = buyer.legalRegistrationIdentifier || buyer.tradingName;
return {
type: isCompany ? 'company' : 'person',
name: buyer.name,
description: '',
address: {
streetName: buyer.postalAddress.addressLine1 || '',
houseNumber: '',
city: buyer.postalAddress.city || '',
postalCode: buyer.postalAddress.postCode || '',
country: buyer.postalAddress.countryCode || ''
},
registrationDetails: {
vatId: buyer.vatIdentifier || '',
registrationId: buyer.identifier || buyer.legalRegistrationIdentifier || '',
registrationName: buyer.name
},
status: 'active',
foundedDate: {
year: new Date().getFullYear(),
month: new Date().getMonth() + 1,
day: new Date().getDate()
}
} as any;
}
/**
* Reverse map invoice lines
*/
private reverseMapInvoiceLines(lines: InvoiceLine[]): EInvoice['items'] {
return lines.map((line, index) => ({
position: index + 1,
name: line.itemInformation.name,
description: line.itemInformation.description || '',
unitQuantity: line.invoicedQuantity,
unitType: line.invoicedQuantityUnitOfMeasureCode,
unitNetPrice: line.priceDetails.itemNetPrice,
vatPercentage: line.vatInformation.rate || 0,
articleNumber: line.itemInformation.sellersIdentifier || ''
}));
}
/**
* Validate semantic model completeness
*/
public validateSemanticModel(model: EN16931SemanticModel): string[] {
const errors: string[] = [];
// Check mandatory fields
if (!model.documentInformation.invoiceNumber) {
errors.push('BT-1: Invoice number is mandatory');
}
if (!model.documentInformation.issueDate) {
errors.push('BT-2: Invoice issue date is mandatory');
}
if (!model.documentInformation.typeCode) {
errors.push('BT-3: Invoice type code is mandatory');
}
if (!model.documentInformation.currencyCode) {
errors.push('BT-5: Invoice currency code is mandatory');
}
if (!model.seller?.name) {
errors.push('BT-27: Seller name is mandatory');
}
if (!model.seller?.postalAddress?.countryCode) {
errors.push('BT-40: Seller country code is mandatory');
}
if (!model.buyer?.name) {
errors.push('BT-44: Buyer name is mandatory');
}
if (!model.buyer?.postalAddress?.countryCode) {
errors.push('BT-55: Buyer country code is mandatory');
}
if (!model.documentTotals) {
errors.push('BG-22: Document totals are mandatory');
}
if (!model.invoiceLines || model.invoiceLines.length === 0) {
errors.push('BG-25: At least one invoice line is mandatory');
}
return errors;
}
}

View File

@@ -0,0 +1,654 @@
/**
* Semantic Model Validator
* Validates invoices against EN16931 Business Terms and Business Groups
*/
import type { ValidationResult } from '../validation/validation.types.js';
import type { EN16931SemanticModel, BusinessTerms, BusinessGroups } from './bt-bg.model.js';
import type { EInvoice } from '../../einvoice.js';
import { SemanticModelAdapter } from './semantic.adapter.js';
/**
* Business Term validation rules
*/
interface BTValidationRule {
btId: string;
description: string;
mandatory: boolean;
validate: (model: EN16931SemanticModel) => ValidationResult | null;
}
/**
* Semantic Model Validator
* Validates against all EN16931 Business Terms (BT) and Business Groups (BG)
*/
export class SemanticModelValidator {
private adapter: SemanticModelAdapter;
private btRules: BTValidationRule[];
constructor() {
this.adapter = new SemanticModelAdapter();
this.btRules = this.initializeBusinessTermRules();
}
/**
* Validate an invoice using the semantic model
*/
public validate(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
// Convert to semantic model
const model = this.adapter.toSemanticModel(invoice);
// Validate all business terms
for (const rule of this.btRules) {
const result = rule.validate(model);
if (result) {
results.push(result);
}
}
// Validate business groups
results.push(...this.validateBusinessGroups(model));
// Validate cardinality constraints
results.push(...this.validateCardinality(model));
// Validate conditional rules
results.push(...this.validateConditionalRules(model));
return results;
}
/**
* Initialize Business Term validation rules
*/
private initializeBusinessTermRules(): BTValidationRule[] {
return [
// Document level mandatory fields
{
btId: 'BT-1',
description: 'Invoice number',
mandatory: true,
validate: (model) => {
if (!model.documentInformation.invoiceNumber) {
return {
ruleId: 'BT-1',
severity: 'error',
message: 'Invoice number is mandatory',
field: 'documentInformation.invoiceNumber',
btReference: 'BT-1',
source: 'SEMANTIC'
};
}
return null;
}
},
{
btId: 'BT-2',
description: 'Invoice issue date',
mandatory: true,
validate: (model) => {
if (!model.documentInformation.issueDate) {
return {
ruleId: 'BT-2',
severity: 'error',
message: 'Invoice issue date is mandatory',
field: 'documentInformation.issueDate',
btReference: 'BT-2',
source: 'SEMANTIC'
};
}
return null;
}
},
{
btId: 'BT-3',
description: 'Invoice type code',
mandatory: true,
validate: (model) => {
if (!model.documentInformation.typeCode) {
return {
ruleId: 'BT-3',
severity: 'error',
message: 'Invoice type code is mandatory',
field: 'documentInformation.typeCode',
btReference: 'BT-3',
source: 'SEMANTIC'
};
}
const validCodes = ['380', '381', '383', '384', '386', '389'];
if (!validCodes.includes(model.documentInformation.typeCode)) {
return {
ruleId: 'BT-3',
severity: 'error',
message: `Invalid invoice type code. Must be one of: ${validCodes.join(', ')}`,
field: 'documentInformation.typeCode',
value: model.documentInformation.typeCode,
btReference: 'BT-3',
source: 'SEMANTIC'
};
}
return null;
}
},
{
btId: 'BT-5',
description: 'Invoice currency code',
mandatory: true,
validate: (model) => {
if (!model.documentInformation.currencyCode) {
return {
ruleId: 'BT-5',
severity: 'error',
message: 'Invoice currency code is mandatory',
field: 'documentInformation.currencyCode',
btReference: 'BT-5',
source: 'SEMANTIC'
};
}
// Validate ISO 4217 currency code
if (!/^[A-Z]{3}$/.test(model.documentInformation.currencyCode)) {
return {
ruleId: 'BT-5',
severity: 'error',
message: 'Currency code must be a valid ISO 4217 code',
field: 'documentInformation.currencyCode',
value: model.documentInformation.currencyCode,
btReference: 'BT-5',
source: 'SEMANTIC'
};
}
return null;
}
},
// Seller mandatory fields
{
btId: 'BT-27',
description: 'Seller name',
mandatory: true,
validate: (model) => {
if (!model.seller?.name) {
return {
ruleId: 'BT-27',
severity: 'error',
message: 'Seller name is mandatory',
field: 'seller.name',
btReference: 'BT-27',
source: 'SEMANTIC'
};
}
return null;
}
},
{
btId: 'BT-40',
description: 'Seller country code',
mandatory: true,
validate: (model) => {
if (!model.seller?.postalAddress?.countryCode) {
return {
ruleId: 'BT-40',
severity: 'error',
message: 'Seller country code is mandatory',
field: 'seller.postalAddress.countryCode',
btReference: 'BT-40',
source: 'SEMANTIC'
};
}
// Validate ISO 3166-1 alpha-2 country code
if (!/^[A-Z]{2}$/.test(model.seller.postalAddress.countryCode)) {
return {
ruleId: 'BT-40',
severity: 'error',
message: 'Country code must be a valid ISO 3166-1 alpha-2 code',
field: 'seller.postalAddress.countryCode',
value: model.seller.postalAddress.countryCode,
btReference: 'BT-40',
source: 'SEMANTIC'
};
}
return null;
}
},
// Buyer mandatory fields
{
btId: 'BT-44',
description: 'Buyer name',
mandatory: true,
validate: (model) => {
if (!model.buyer?.name) {
return {
ruleId: 'BT-44',
severity: 'error',
message: 'Buyer name is mandatory',
field: 'buyer.name',
btReference: 'BT-44',
source: 'SEMANTIC'
};
}
return null;
}
},
{
btId: 'BT-55',
description: 'Buyer country code',
mandatory: true,
validate: (model) => {
if (!model.buyer?.postalAddress?.countryCode) {
return {
ruleId: 'BT-55',
severity: 'error',
message: 'Buyer country code is mandatory',
field: 'buyer.postalAddress.countryCode',
btReference: 'BT-55',
source: 'SEMANTIC'
};
}
// Validate ISO 3166-1 alpha-2 country code
if (!/^[A-Z]{2}$/.test(model.buyer.postalAddress.countryCode)) {
return {
ruleId: 'BT-55',
severity: 'error',
message: 'Country code must be a valid ISO 3166-1 alpha-2 code',
field: 'buyer.postalAddress.countryCode',
value: model.buyer.postalAddress.countryCode,
btReference: 'BT-55',
source: 'SEMANTIC'
};
}
return null;
}
},
// Payment means
{
btId: 'BT-81',
description: 'Payment means type code',
mandatory: true,
validate: (model) => {
if (!model.paymentInstructions?.paymentMeansTypeCode) {
return {
ruleId: 'BT-81',
severity: 'error',
message: 'Payment means type code is mandatory',
field: 'paymentInstructions.paymentMeansTypeCode',
btReference: 'BT-81',
source: 'SEMANTIC'
};
}
return null;
}
},
// Document totals
{
btId: 'BT-106',
description: 'Sum of invoice line net amount',
mandatory: true,
validate: (model) => {
if (model.documentTotals?.lineExtensionAmount === undefined) {
return {
ruleId: 'BT-106',
severity: 'error',
message: 'Sum of invoice line net amount is mandatory',
field: 'documentTotals.lineExtensionAmount',
btReference: 'BT-106',
source: 'SEMANTIC'
};
}
return null;
}
},
{
btId: 'BT-109',
description: 'Invoice total amount without VAT',
mandatory: true,
validate: (model) => {
if (model.documentTotals?.taxExclusiveAmount === undefined) {
return {
ruleId: 'BT-109',
severity: 'error',
message: 'Invoice total amount without VAT is mandatory',
field: 'documentTotals.taxExclusiveAmount',
btReference: 'BT-109',
source: 'SEMANTIC'
};
}
return null;
}
},
{
btId: 'BT-112',
description: 'Invoice total amount with VAT',
mandatory: true,
validate: (model) => {
if (model.documentTotals?.taxInclusiveAmount === undefined) {
return {
ruleId: 'BT-112',
severity: 'error',
message: 'Invoice total amount with VAT is mandatory',
field: 'documentTotals.taxInclusiveAmount',
btReference: 'BT-112',
source: 'SEMANTIC'
};
}
return null;
}
},
{
btId: 'BT-115',
description: 'Amount due for payment',
mandatory: true,
validate: (model) => {
if (model.documentTotals?.payableAmount === undefined) {
return {
ruleId: 'BT-115',
severity: 'error',
message: 'Amount due for payment is mandatory',
field: 'documentTotals.payableAmount',
btReference: 'BT-115',
source: 'SEMANTIC'
};
}
return null;
}
}
];
}
/**
* Validate Business Groups
*/
private validateBusinessGroups(model: EN16931SemanticModel): ValidationResult[] {
const results: ValidationResult[] = [];
// BG-4: Seller
if (!model.seller) {
results.push({
ruleId: 'BG-4',
severity: 'error',
message: 'Seller information is mandatory',
field: 'seller',
bgReference: 'BG-4',
source: 'SEMANTIC'
});
}
// BG-5: Seller postal address
if (!model.seller?.postalAddress) {
results.push({
ruleId: 'BG-5',
severity: 'error',
message: 'Seller postal address is mandatory',
field: 'seller.postalAddress',
bgReference: 'BG-5',
source: 'SEMANTIC'
});
}
// BG-7: Buyer
if (!model.buyer) {
results.push({
ruleId: 'BG-7',
severity: 'error',
message: 'Buyer information is mandatory',
field: 'buyer',
bgReference: 'BG-7',
source: 'SEMANTIC'
});
}
// BG-8: Buyer postal address
if (!model.buyer?.postalAddress) {
results.push({
ruleId: 'BG-8',
severity: 'error',
message: 'Buyer postal address is mandatory',
field: 'buyer.postalAddress',
bgReference: 'BG-8',
source: 'SEMANTIC'
});
}
// BG-16: Payment instructions
if (!model.paymentInstructions) {
results.push({
ruleId: 'BG-16',
severity: 'error',
message: 'Payment instructions are mandatory',
field: 'paymentInstructions',
bgReference: 'BG-16',
source: 'SEMANTIC'
});
}
// BG-22: Document totals
if (!model.documentTotals) {
results.push({
ruleId: 'BG-22',
severity: 'error',
message: 'Document totals are mandatory',
field: 'documentTotals',
bgReference: 'BG-22',
source: 'SEMANTIC'
});
}
// BG-25: Invoice lines
if (!model.invoiceLines || model.invoiceLines.length === 0) {
results.push({
ruleId: 'BG-25',
severity: 'error',
message: 'At least one invoice line is mandatory',
field: 'invoiceLines',
bgReference: 'BG-25',
source: 'SEMANTIC'
});
}
// Validate each invoice line
model.invoiceLines?.forEach((line, index) => {
// BT-126: Line identifier
if (!line.identifier) {
results.push({
ruleId: 'BT-126',
severity: 'error',
message: `Invoice line ${index + 1}: Identifier is mandatory`,
field: `invoiceLines[${index}].identifier`,
btReference: 'BT-126',
source: 'SEMANTIC'
});
}
// BT-129: Invoiced quantity
if (line.invoicedQuantity === undefined) {
results.push({
ruleId: 'BT-129',
severity: 'error',
message: `Invoice line ${index + 1}: Invoiced quantity is mandatory`,
field: `invoiceLines[${index}].invoicedQuantity`,
btReference: 'BT-129',
source: 'SEMANTIC'
});
}
// BT-131: Line net amount
if (line.lineExtensionAmount === undefined) {
results.push({
ruleId: 'BT-131',
severity: 'error',
message: `Invoice line ${index + 1}: Line net amount is mandatory`,
field: `invoiceLines[${index}].lineExtensionAmount`,
btReference: 'BT-131',
source: 'SEMANTIC'
});
}
// BT-153: Item name
if (!line.itemInformation?.name) {
results.push({
ruleId: 'BT-153',
severity: 'error',
message: `Invoice line ${index + 1}: Item name is mandatory`,
field: `invoiceLines[${index}].itemInformation.name`,
btReference: 'BT-153',
source: 'SEMANTIC'
});
}
});
return results;
}
/**
* Validate cardinality constraints
*/
private validateCardinality(model: EN16931SemanticModel): ValidationResult[] {
const results: ValidationResult[] = [];
// Check for duplicate invoice lines
const lineIds = model.invoiceLines?.map(l => l.identifier) || [];
const uniqueIds = new Set(lineIds);
if (lineIds.length !== uniqueIds.size) {
results.push({
ruleId: 'CARD-01',
severity: 'error',
message: 'Invoice line identifiers must be unique',
field: 'invoiceLines',
source: 'SEMANTIC'
});
}
// Check VAT breakdown cardinality
if (model.vatBreakdown) {
const vatCategories = model.vatBreakdown.map(v => v.vatCategoryCode);
const uniqueCategories = new Set(vatCategories);
if (vatCategories.length !== uniqueCategories.size) {
results.push({
ruleId: 'CARD-02',
severity: 'error',
message: 'Each VAT category code must appear only once in VAT breakdown',
field: 'vatBreakdown',
source: 'SEMANTIC'
});
}
}
return results;
}
/**
* Validate conditional rules
*/
private validateConditionalRules(model: EN16931SemanticModel): ValidationResult[] {
const results: ValidationResult[] = [];
// If VAT accounting currency code is present, VAT amount in accounting currency must be present
if (model.documentInformation.currencyCode !== model.documentInformation.currencyCode) {
if (!model.documentTotals?.taxInclusiveAmount) {
results.push({
ruleId: 'COND-01',
severity: 'error',
message: 'When VAT accounting currency differs from invoice currency, VAT amount in accounting currency is mandatory',
field: 'documentTotals.taxInclusiveAmount',
source: 'SEMANTIC'
});
}
}
// If credit note, there should be a preceding invoice reference
if (model.documentInformation.typeCode === '381') {
if (!model.references?.precedingInvoices || model.references.precedingInvoices.length === 0) {
results.push({
ruleId: 'COND-02',
severity: 'warning',
message: 'Credit notes should reference the original invoice',
field: 'references.precedingInvoices',
source: 'SEMANTIC'
});
}
}
// If tax representative is present, certain fields are mandatory
if (model.taxRepresentative) {
if (!model.taxRepresentative.vatIdentifier) {
results.push({
ruleId: 'COND-03',
severity: 'error',
message: 'Tax representative VAT identifier is mandatory when tax representative is present',
field: 'taxRepresentative.vatIdentifier',
source: 'SEMANTIC'
});
}
}
// VAT exemption requires exemption reason
if (model.vatBreakdown) {
for (const vat of model.vatBreakdown) {
if (vat.vatCategoryCode === 'E' && !vat.vatExemptionReasonText && !vat.vatExemptionReasonCode) {
results.push({
ruleId: 'COND-04',
severity: 'error',
message: 'VAT exemption requires exemption reason text or code',
field: 'vatBreakdown.vatExemptionReasonText',
source: 'SEMANTIC'
});
}
}
}
return results;
}
/**
* Get semantic model from invoice
*/
public getSemanticModel(invoice: EInvoice): EN16931SemanticModel {
return this.adapter.toSemanticModel(invoice);
}
/**
* Create invoice from semantic model
*/
public createInvoice(model: EN16931SemanticModel): EInvoice {
return this.adapter.fromSemanticModel(model);
}
/**
* Get BT/BG mapping for an invoice
*/
public getBusinessTermMapping(invoice: EInvoice): Map<string, any> {
const model = this.adapter.toSemanticModel(invoice);
const mapping = new Map<string, any>();
// Map all business terms
mapping.set('BT-1', model.documentInformation.invoiceNumber);
mapping.set('BT-2', model.documentInformation.issueDate);
mapping.set('BT-3', model.documentInformation.typeCode);
mapping.set('BT-5', model.documentInformation.currencyCode);
mapping.set('BT-10', model.references?.buyerReference);
mapping.set('BT-27', model.seller?.name);
mapping.set('BT-40', model.seller?.postalAddress?.countryCode);
mapping.set('BT-44', model.buyer?.name);
mapping.set('BT-55', model.buyer?.postalAddress?.countryCode);
mapping.set('BT-81', model.paymentInstructions?.paymentMeansTypeCode);
mapping.set('BT-106', model.documentTotals?.lineExtensionAmount);
mapping.set('BT-109', model.documentTotals?.taxExclusiveAmount);
mapping.set('BT-112', model.documentTotals?.taxInclusiveAmount);
mapping.set('BT-115', model.documentTotals?.payableAmount);
// Map business groups
mapping.set('BG-4', model.seller);
mapping.set('BG-5', model.seller?.postalAddress);
mapping.set('BG-7', model.buyer);
mapping.set('BG-8', model.buyer?.postalAddress);
mapping.set('BG-16', model.paymentInstructions);
mapping.set('BG-22', model.documentTotals);
mapping.set('BG-25', model.invoiceLines);
return mapping;
}
}

View File

@@ -0,0 +1,323 @@
/**
* Currency Calculator using Decimal Arithmetic
* EN16931-compliant monetary calculations with exact precision
*/
import { Decimal, decimal, RoundingMode } from './decimal.js';
import type { TCurrency } from '@tsclass/tsclass/dist_ts/finance/index.js';
import { getCurrencyMinorUnits } from './currency.utils.js';
/**
* Currency-aware calculator using decimal arithmetic for EN16931 compliance
*/
export class DecimalCurrencyCalculator {
private readonly currency: TCurrency;
private readonly minorUnits: number;
private readonly roundingMode: RoundingMode;
constructor(
currency: TCurrency,
roundingMode: RoundingMode = 'HALF_UP'
) {
this.currency = currency;
this.minorUnits = getCurrencyMinorUnits(currency);
this.roundingMode = roundingMode;
}
/**
* Round a decimal value according to currency rules
*/
round(value: Decimal | number | string): Decimal {
const decimalValue = value instanceof Decimal ? value : new Decimal(value);
return decimalValue.round(this.minorUnits, this.roundingMode);
}
/**
* Calculate line net amount: (quantity × unitPrice) - discount
*/
calculateLineNet(
quantity: Decimal | number | string,
unitPrice: Decimal | number | string,
discount: Decimal | number | string = '0'
): Decimal {
const qty = quantity instanceof Decimal ? quantity : new Decimal(quantity);
const price = unitPrice instanceof Decimal ? unitPrice : new Decimal(unitPrice);
const disc = discount instanceof Decimal ? discount : new Decimal(discount);
const gross = qty.multiply(price);
const net = gross.subtract(disc);
return this.round(net);
}
/**
* Calculate VAT amount from base and rate
*/
calculateVAT(
baseAmount: Decimal | number | string,
vatRate: Decimal | number | string
): Decimal {
const base = baseAmount instanceof Decimal ? baseAmount : new Decimal(baseAmount);
const rate = vatRate instanceof Decimal ? vatRate : new Decimal(vatRate);
const vat = base.percentage(rate);
return this.round(vat);
}
/**
* Calculate total with VAT
*/
calculateGrossAmount(
netAmount: Decimal | number | string,
vatAmount: Decimal | number | string
): Decimal {
const net = netAmount instanceof Decimal ? netAmount : new Decimal(netAmount);
const vat = vatAmount instanceof Decimal ? vatAmount : new Decimal(vatAmount);
return this.round(net.add(vat));
}
/**
* Calculate sum of line items
*/
sumLineItems(items: Array<{
quantity: Decimal | number | string;
unitPrice: Decimal | number | string;
discount?: Decimal | number | string;
}>): Decimal {
let total = Decimal.ZERO;
for (const item of items) {
const lineNet = this.calculateLineNet(
item.quantity,
item.unitPrice,
item.discount
);
total = total.add(lineNet);
}
return this.round(total);
}
/**
* Calculate VAT breakdown by rate
*/
calculateVATBreakdown(items: Array<{
netAmount: Decimal | number | string;
vatRate: Decimal | number | string;
}>): Array<{
rate: Decimal;
baseAmount: Decimal;
vatAmount: Decimal;
}> {
// Group by VAT rate
const groups = new Map<string, {
rate: Decimal;
baseAmount: Decimal;
}>();
for (const item of items) {
const net = item.netAmount instanceof Decimal ? item.netAmount : new Decimal(item.netAmount);
const rate = item.vatRate instanceof Decimal ? item.vatRate : new Decimal(item.vatRate);
const rateKey = rate.toString();
if (groups.has(rateKey)) {
const group = groups.get(rateKey)!;
group.baseAmount = group.baseAmount.add(net);
} else {
groups.set(rateKey, {
rate,
baseAmount: net
});
}
}
// Calculate VAT for each group
const breakdown: Array<{
rate: Decimal;
baseAmount: Decimal;
vatAmount: Decimal;
}> = [];
for (const group of groups.values()) {
breakdown.push({
rate: group.rate,
baseAmount: this.round(group.baseAmount),
vatAmount: this.calculateVAT(group.baseAmount, group.rate)
});
}
return breakdown;
}
/**
* Check if two amounts are equal within currency precision
*/
areEqual(
amount1: Decimal | number | string,
amount2: Decimal | number | string
): boolean {
const a1 = amount1 instanceof Decimal ? amount1 : new Decimal(amount1);
const a2 = amount2 instanceof Decimal ? amount2 : new Decimal(amount2);
// Round both to currency precision before comparing
const rounded1 = this.round(a1);
const rounded2 = this.round(a2);
return rounded1.equals(rounded2);
}
/**
* Calculate payment terms discount
*/
calculatePaymentDiscount(
amount: Decimal | number | string,
discountRate: Decimal | number | string
): Decimal {
const amt = amount instanceof Decimal ? amount : new Decimal(amount);
const rate = discountRate instanceof Decimal ? discountRate : new Decimal(discountRate);
const discount = amt.percentage(rate);
return this.round(discount);
}
/**
* Distribute a total amount across items proportionally
*/
distributeAmount(
totalToDistribute: Decimal | number | string,
items: Array<{ value: Decimal | number | string }>
): Decimal[] {
const total = totalToDistribute instanceof Decimal ? totalToDistribute : new Decimal(totalToDistribute);
// Calculate sum of all item values
const itemSum = items.reduce((sum, item) => {
const value = item.value instanceof Decimal ? item.value : new Decimal(item.value);
return sum.add(value);
}, Decimal.ZERO);
if (itemSum.isZero()) {
// Can't distribute if sum is zero
return items.map(() => Decimal.ZERO);
}
const distributed: Decimal[] = [];
let distributedSum = Decimal.ZERO;
// Distribute proportionally
for (let i = 0; i < items.length; i++) {
const itemValue = items[i].value instanceof Decimal ? items[i].value : new Decimal(items[i].value);
if (i === items.length - 1) {
// Last item gets the remainder to avoid rounding errors
distributed.push(total.subtract(distributedSum));
} else {
const itemDecimal = itemValue instanceof Decimal ? itemValue : new Decimal(itemValue);
const proportion = itemDecimal.divide(itemSum);
const distributedAmount = this.round(total.multiply(proportion));
distributed.push(distributedAmount);
distributedSum = distributedSum.add(distributedAmount);
}
}
return distributed;
}
/**
* Calculate compound amount (e.g., for multiple charges/allowances)
*/
calculateCompoundAmount(
baseAmount: Decimal | number | string,
adjustments: Array<{
type: 'charge' | 'allowance';
value: Decimal | number | string;
isPercentage?: boolean;
}>
): Decimal {
let result = baseAmount instanceof Decimal ? baseAmount : new Decimal(baseAmount);
for (const adjustment of adjustments) {
const value = adjustment.value instanceof Decimal ? adjustment.value : new Decimal(adjustment.value);
let adjustmentAmount: Decimal;
if (adjustment.isPercentage) {
adjustmentAmount = result.percentage(value);
} else {
adjustmentAmount = value;
}
if (adjustment.type === 'charge') {
result = result.add(adjustmentAmount);
} else {
result = result.subtract(adjustmentAmount);
}
}
return this.round(result);
}
/**
* Validate monetary calculation according to EN16931 rules
*/
validateCalculation(
expected: Decimal | number | string,
calculated: Decimal | number | string,
ruleName: string
): {
valid: boolean;
expected: string;
calculated: string;
difference?: string;
rule: string;
} {
const exp = expected instanceof Decimal ? expected : new Decimal(expected);
const calc = calculated instanceof Decimal ? calculated : new Decimal(calculated);
const roundedExp = this.round(exp);
const roundedCalc = this.round(calc);
const valid = roundedExp.equals(roundedCalc);
return {
valid,
expected: roundedExp.toFixed(this.minorUnits),
calculated: roundedCalc.toFixed(this.minorUnits),
difference: valid ? undefined : roundedExp.subtract(roundedCalc).abs().toFixed(this.minorUnits),
rule: ruleName
};
}
/**
* Format amount for display
*/
formatAmount(amount: Decimal | number | string): string {
const amt = amount instanceof Decimal ? amount : new Decimal(amount);
const rounded = this.round(amt);
return `${rounded.toFixed(this.minorUnits)} ${this.currency}`;
}
/**
* Get currency information
*/
getCurrencyInfo(): {
code: TCurrency;
minorUnits: number;
roundingMode: RoundingMode;
} {
return {
code: this.currency,
minorUnits: this.minorUnits,
roundingMode: this.roundingMode
};
}
}
/**
* Factory function to create a decimal currency calculator
*/
export function createDecimalCalculator(
currency: TCurrency,
roundingMode?: RoundingMode
): DecimalCurrencyCalculator {
return new DecimalCurrencyCalculator(currency, roundingMode);
}

509
ts/formats/utils/decimal.ts Normal file
View File

@@ -0,0 +1,509 @@
/**
* Decimal Arithmetic Library for EN16931 Compliance
* Provides arbitrary precision decimal arithmetic to avoid floating-point errors
*
* Based on EN16931 requirements for financial calculations:
* - All monetary amounts must be calculated with sufficient precision
* - Rounding must be consistent and predictable
* - No loss of precision in intermediate calculations
*/
/**
* Decimal class for arbitrary precision arithmetic
* Internally stores the value as an integer with a scale factor
*/
export class Decimal {
private readonly value: bigint;
private readonly scale: number;
// Constants - initialized lazily to avoid initialization issues
private static _ZERO: Decimal | undefined;
private static _ONE: Decimal | undefined;
private static _TEN: Decimal | undefined;
private static _HUNDRED: Decimal | undefined;
static get ZERO(): Decimal {
if (!this._ZERO) this._ZERO = new Decimal(0);
return this._ZERO;
}
static get ONE(): Decimal {
if (!this._ONE) this._ONE = new Decimal(1);
return this._ONE;
}
static get TEN(): Decimal {
if (!this._TEN) this._TEN = new Decimal(10);
return this._TEN;
}
static get HUNDRED(): Decimal {
if (!this._HUNDRED) this._HUNDRED = new Decimal(100);
return this._HUNDRED;
}
// Default scale for monetary calculations (4 decimal places for intermediate calculations)
private static readonly DEFAULT_SCALE = 4;
/**
* Create a new Decimal from various input types
*/
constructor(value: string | number | bigint | Decimal, scale?: number) {
if (value instanceof Decimal) {
this.value = value.value;
this.scale = value.scale;
return;
}
// Special handling for direct bigint with scale (internal use)
if (typeof value === 'bigint' && scale !== undefined) {
this.value = value;
this.scale = scale;
return;
}
// Determine scale if not provided
if (scale === undefined) {
if (typeof value === 'string') {
const parts = value.split('.');
scale = parts.length > 1 ? parts[1].length : 0;
} else {
scale = Decimal.DEFAULT_SCALE;
}
}
this.scale = scale;
// Convert to scaled integer
if (typeof value === 'string') {
// Remove any formatting
value = value.replace(/[^\d.-]/g, '');
const parts = value.split('.');
const integerPart = parts[0] || '0';
const decimalPart = (parts[1] || '').padEnd(scale, '0').slice(0, scale);
this.value = BigInt(integerPart + decimalPart);
} else if (typeof value === 'number') {
// Handle floating point numbers
if (!isFinite(value)) {
throw new Error(`Invalid number value: ${value}`);
}
const multiplier = Math.pow(10, scale);
this.value = BigInt(Math.round(value * multiplier));
} else {
// bigint
this.value = value * BigInt(Math.pow(10, scale));
}
}
/**
* Convert to string representation
*/
toString(decimalPlaces?: number): string {
const absValue = this.value < 0n ? -this.value : this.value;
const str = absValue.toString().padStart(this.scale + 1, '0');
const integerPart = this.scale > 0 ? (str.slice(0, -this.scale) || '0') : str;
let decimalPart = this.scale > 0 ? str.slice(-this.scale) : '';
// Apply decimal places if specified
if (decimalPlaces !== undefined) {
if (decimalPlaces === 0) {
return (this.value < 0n ? '-' : '') + integerPart;
}
decimalPart = decimalPart.padEnd(decimalPlaces, '0').slice(0, decimalPlaces);
}
// Remove trailing zeros if no specific decimal places requested
if (decimalPlaces === undefined) {
decimalPart = decimalPart.replace(/0+$/, '');
}
const result = decimalPart ? `${integerPart}.${decimalPart}` : integerPart;
return this.value < 0n ? '-' + result : result;
}
/**
* Convert to number (may lose precision)
*/
toNumber(): number {
return Number(this.value) / Math.pow(10, this.scale);
}
/**
* Convert to fixed decimal places string
*/
toFixed(decimalPlaces: number): string {
return this.round(decimalPlaces).toString(decimalPlaces);
}
/**
* Add two decimals
*/
add(other: Decimal | number | string): Decimal {
const otherDecimal = other instanceof Decimal ? other : new Decimal(other);
// Align scales
if (this.scale === otherDecimal.scale) {
return new Decimal(this.value + otherDecimal.value, this.scale);
}
const maxScale = Math.max(this.scale, otherDecimal.scale);
const thisScaled = this.rescale(maxScale);
const otherScaled = otherDecimal.rescale(maxScale);
return new Decimal(thisScaled.value + otherScaled.value, maxScale);
}
/**
* Subtract another decimal
*/
subtract(other: Decimal | number | string): Decimal {
const otherDecimal = other instanceof Decimal ? other : new Decimal(other);
// Align scales
if (this.scale === otherDecimal.scale) {
return new Decimal(this.value - otherDecimal.value, this.scale);
}
const maxScale = Math.max(this.scale, otherDecimal.scale);
const thisScaled = this.rescale(maxScale);
const otherScaled = otherDecimal.rescale(maxScale);
return new Decimal(thisScaled.value - otherScaled.value, maxScale);
}
/**
* Multiply by another decimal
*/
multiply(other: Decimal | number | string): Decimal {
const otherDecimal = other instanceof Decimal ? other : new Decimal(other);
// Multiply values and add scales
const newValue = this.value * otherDecimal.value;
const newScale = this.scale + otherDecimal.scale;
// Reduce scale if possible to avoid overflow
const result = new Decimal(newValue, newScale);
return result.normalize();
}
/**
* Divide by another decimal
*/
divide(other: Decimal | number | string, precision: number = 10): Decimal {
const otherDecimal = other instanceof Decimal ? other : new Decimal(other);
if (otherDecimal.value === 0n) {
throw new Error('Division by zero');
}
// Scale up the dividend to maintain precision
const scaledDividend = this.value * BigInt(Math.pow(10, precision));
const quotient = scaledDividend / otherDecimal.value;
return new Decimal(quotient, this.scale + precision - otherDecimal.scale).normalize();
}
/**
* Calculate percentage (this * rate / 100)
*/
percentage(rate: Decimal | number | string): Decimal {
const rateDecimal = rate instanceof Decimal ? rate : new Decimal(rate);
return this.multiply(rateDecimal).divide(100);
}
/**
* Round to specified decimal places using a specific rounding mode
*/
round(decimalPlaces: number, mode: 'HALF_UP' | 'HALF_DOWN' | 'HALF_EVEN' | 'UP' | 'DOWN' | 'CEILING' | 'FLOOR' = 'HALF_UP'): Decimal {
if (decimalPlaces === this.scale) {
return this;
}
if (decimalPlaces > this.scale) {
// Just add zeros
return this.rescale(decimalPlaces);
}
// Need to round
const factor = BigInt(Math.pow(10, this.scale - decimalPlaces));
const halfFactor = factor / 2n;
let rounded: bigint;
const isNegative = this.value < 0n;
const absValue = isNegative ? -this.value : this.value;
switch (mode) {
case 'HALF_UP':
// Round half away from zero
rounded = (absValue + halfFactor) / factor;
break;
case 'HALF_DOWN':
// Round half toward zero
rounded = (absValue + halfFactor - 1n) / factor;
break;
case 'HALF_EVEN':
// Banker's rounding
const quotient = absValue / factor;
const remainder = absValue % factor;
if (remainder > halfFactor || (remainder === halfFactor && quotient % 2n === 1n)) {
rounded = quotient + 1n;
} else {
rounded = quotient;
}
break;
case 'UP':
// Round away from zero
rounded = (absValue + factor - 1n) / factor;
break;
case 'DOWN':
// Round toward zero
rounded = absValue / factor;
break;
case 'CEILING':
// Round toward positive infinity
if (isNegative) {
rounded = absValue / factor;
} else {
rounded = (absValue + factor - 1n) / factor;
}
break;
case 'FLOOR':
// Round toward negative infinity
if (isNegative) {
rounded = (absValue + factor - 1n) / factor;
} else {
rounded = absValue / factor;
}
break;
default:
throw new Error(`Unknown rounding mode: ${mode}`);
}
const finalValue = isNegative ? -rounded : rounded;
return new Decimal(finalValue, decimalPlaces);
}
/**
* Compare with another decimal
*/
compareTo(other: Decimal | number | string): number {
const otherDecimal = other instanceof Decimal ? other : new Decimal(other);
// Align scales for comparison
if (this.scale === otherDecimal.scale) {
if (this.value < otherDecimal.value) return -1;
if (this.value > otherDecimal.value) return 1;
return 0;
}
const maxScale = Math.max(this.scale, otherDecimal.scale);
const thisScaled = this.rescale(maxScale);
const otherScaled = otherDecimal.rescale(maxScale);
if (thisScaled.value < otherScaled.value) return -1;
if (thisScaled.value > otherScaled.value) return 1;
return 0;
}
/**
* Check equality
*/
equals(other: Decimal | number | string, tolerance?: Decimal | number | string): boolean {
if (tolerance) {
const toleranceDecimal = tolerance instanceof Decimal ? tolerance : new Decimal(tolerance);
const diff = this.subtract(other);
const absDiff = diff.abs();
return absDiff.compareTo(toleranceDecimal) <= 0;
}
return this.compareTo(other) === 0;
}
/**
* Check if less than
*/
lessThan(other: Decimal | number | string): boolean {
return this.compareTo(other) < 0;
}
/**
* Check if less than or equal
*/
lessThanOrEqual(other: Decimal | number | string): boolean {
return this.compareTo(other) <= 0;
}
/**
* Check if greater than
*/
greaterThan(other: Decimal | number | string): boolean {
return this.compareTo(other) > 0;
}
/**
* Check if greater than or equal
*/
greaterThanOrEqual(other: Decimal | number | string): boolean {
return this.compareTo(other) >= 0;
}
/**
* Get absolute value
*/
abs(): Decimal {
return this.value < 0n ? new Decimal(-this.value, this.scale) : this;
}
/**
* Negate the value
*/
negate(): Decimal {
return new Decimal(-this.value, this.scale);
}
/**
* Check if zero
*/
isZero(): boolean {
return this.value === 0n;
}
/**
* Check if negative
*/
isNegative(): boolean {
return this.value < 0n;
}
/**
* Check if positive
*/
isPositive(): boolean {
return this.value > 0n;
}
/**
* Rescale to a different number of decimal places
*/
private rescale(newScale: number): Decimal {
if (newScale === this.scale) {
return this;
}
if (newScale > this.scale) {
// Add zeros
const factor = BigInt(Math.pow(10, newScale - this.scale));
return new Decimal(this.value * factor, newScale);
}
// This would lose precision, use round() instead
throw new Error('Use round() to reduce scale');
}
/**
* Normalize by removing trailing zeros
*/
private normalize(): Decimal {
if (this.value === 0n) {
return new Decimal(0n, 0);
}
let value = this.value;
let scale = this.scale;
while (scale > 0 && value % 10n === 0n) {
value = value / 10n;
scale--;
}
return new Decimal(value, scale);
}
/**
* Create a Decimal from a percentage string (e.g., "19%" -> 0.19)
*/
static fromPercentage(value: string): Decimal {
const cleaned = value.replace('%', '').trim();
return new Decimal(cleaned).divide(100);
}
/**
* Sum an array of decimals
*/
static sum(values: (Decimal | number | string)[]): Decimal {
return values.reduce<Decimal>((acc, val) => {
const decimal = val instanceof Decimal ? val : new Decimal(val);
return acc.add(decimal);
}, Decimal.ZERO);
}
/**
* Get the minimum value
*/
static min(...values: (Decimal | number | string)[]): Decimal {
if (values.length === 0) {
throw new Error('No values provided');
}
let min = values[0] instanceof Decimal ? values[0] : new Decimal(values[0]);
for (let i = 1; i < values.length; i++) {
const current = values[i] instanceof Decimal ? values[i] : new Decimal(values[i]);
const currentDecimal = current instanceof Decimal ? current : new Decimal(current);
if (currentDecimal.lessThan(min)) {
min = currentDecimal;
}
}
return min;
}
/**
* Get the maximum value
*/
static max(...values: (Decimal | number | string)[]): Decimal {
if (values.length === 0) {
throw new Error('No values provided');
}
let max = values[0] instanceof Decimal ? values[0] : new Decimal(values[0]);
for (let i = 1; i < values.length; i++) {
const current = values[i] instanceof Decimal ? values[i] : new Decimal(values[i]);
const currentDecimal = current instanceof Decimal ? current : new Decimal(current);
if (currentDecimal.greaterThan(max)) {
max = currentDecimal;
}
}
return max;
}
}
/**
* Helper function to create a Decimal
*/
export function decimal(value: string | number | bigint | Decimal): Decimal {
return new Decimal(value);
}
/**
* Export commonly used rounding modes
*/
export const RoundingMode = {
HALF_UP: 'HALF_UP' as const,
HALF_DOWN: 'HALF_DOWN' as const,
HALF_EVEN: 'HALF_EVEN' as const,
UP: 'UP' as const,
DOWN: 'DOWN' as const,
CEILING: 'CEILING' as const,
FLOOR: 'FLOOR' as const
} as const;
export type RoundingMode = typeof RoundingMode[keyof typeof RoundingMode];

View File

@@ -2,6 +2,8 @@ import * as plugins from '../../plugins.js';
import type { TAccountingDocItem } from '@tsclass/tsclass/dist_ts/finance/index.js';
import type { EInvoice } from '../../einvoice.js';
import { CurrencyCalculator, areMonetaryValuesEqual } from '../utils/currency.utils.js';
import { DecimalCurrencyCalculator } from '../utils/currency.calculator.decimal.js';
import { Decimal } from '../utils/decimal.js';
import type { ValidationResult, ValidationOptions } from './validation.types.js';
/**
@@ -11,6 +13,7 @@ import type { ValidationResult, ValidationOptions } from './validation.types.js'
export class EN16931BusinessRulesValidator {
private results: ValidationResult[] = [];
private currencyCalculator?: CurrencyCalculator;
private decimalCalculator?: DecimalCurrencyCalculator;
/**
* Validate an invoice against EN16931 business rules
@@ -18,9 +21,10 @@ export class EN16931BusinessRulesValidator {
public validate(invoice: EInvoice, options: ValidationOptions = {}): ValidationResult[] {
this.results = [];
// Initialize currency calculator if currency is available
// Initialize currency calculators if currency is available
if (invoice.currency) {
this.currencyCalculator = new CurrencyCalculator(invoice.currency);
this.decimalCalculator = new DecimalCurrencyCalculator(invoice.currency);
}
// Document level rules (BR-01 to BR-65)
@@ -118,100 +122,139 @@ export class EN16931BusinessRulesValidator {
private validateCalculationRules(invoice: EInvoice): void {
if (!invoice.items || invoice.items.length === 0) return;
// BR-CO-10: Sum of Invoice line net amount = Σ(Invoice line net amount)
const calculatedLineTotal = this.calculateLineTotal(invoice.items);
const declaredLineTotal = invoice.totalNet || 0;
// Use decimal calculator for precise calculations
const useDecimal = this.decimalCalculator !== undefined;
const isEqual = this.currencyCalculator
? this.currencyCalculator.areEqual(calculatedLineTotal, declaredLineTotal)
: Math.abs(calculatedLineTotal - declaredLineTotal) < 0.01;
// BR-CO-10: Sum of Invoice line net amount = Σ(Invoice line net amount)
const calculatedLineTotal = useDecimal
? this.calculateLineTotalDecimal(invoice.items)
: this.calculateLineTotal(invoice.items);
const declaredLineTotal = useDecimal
? new Decimal(invoice.totalNet || 0)
: invoice.totalNet || 0;
const isEqual = useDecimal
? this.decimalCalculator!.areEqual(calculatedLineTotal, declaredLineTotal)
: this.currencyCalculator
? this.currencyCalculator.areEqual(calculatedLineTotal as number, declaredLineTotal as number)
: Math.abs((calculatedLineTotal as number) - (declaredLineTotal as number)) < 0.01;
if (!isEqual) {
this.addError(
'BR-CO-10',
`Sum of line net amounts (${calculatedLineTotal.toFixed(2)}) does not match declared total (${declaredLineTotal.toFixed(2)})`,
`Sum of line net amounts (${useDecimal ? (calculatedLineTotal as Decimal).toFixed(2) : (calculatedLineTotal as number).toFixed(2)}) does not match declared total (${useDecimal ? (declaredLineTotal as Decimal).toFixed(2) : (declaredLineTotal as number).toFixed(2)})`,
'totalNet',
declaredLineTotal,
calculatedLineTotal
useDecimal ? (declaredLineTotal as Decimal).toNumber() : declaredLineTotal as number,
useDecimal ? (calculatedLineTotal as Decimal).toNumber() : calculatedLineTotal as number
);
}
// BR-CO-11: Sum of allowances on document level
const documentAllowances = this.calculateDocumentAllowances(invoice);
const documentAllowances = useDecimal
? this.calculateDocumentAllowancesDecimal(invoice)
: this.calculateDocumentAllowances(invoice);
// BR-CO-12: Sum of charges on document level
const documentCharges = this.calculateDocumentCharges(invoice);
const documentCharges = useDecimal
? this.calculateDocumentChargesDecimal(invoice)
: this.calculateDocumentCharges(invoice);
// BR-CO-13: Invoice total without VAT = Σ(line) - allowances + charges
const expectedTaxExclusive = calculatedLineTotal - documentAllowances + documentCharges;
const declaredTaxExclusive = invoice.totalNet || 0;
const expectedTaxExclusive = useDecimal
? (calculatedLineTotal as Decimal).subtract(documentAllowances).add(documentCharges)
: (calculatedLineTotal as number) - (documentAllowances as number) + (documentCharges as number);
const declaredTaxExclusive = useDecimal
? new Decimal(invoice.totalNet || 0)
: invoice.totalNet || 0;
const isTaxExclusiveEqual = this.currencyCalculator
? this.currencyCalculator.areEqual(expectedTaxExclusive, declaredTaxExclusive)
: Math.abs(expectedTaxExclusive - declaredTaxExclusive) < 0.01;
const isTaxExclusiveEqual = useDecimal
? this.decimalCalculator!.areEqual(expectedTaxExclusive, declaredTaxExclusive)
: this.currencyCalculator
? this.currencyCalculator.areEqual(expectedTaxExclusive as number, declaredTaxExclusive as number)
: Math.abs((expectedTaxExclusive as number) - (declaredTaxExclusive as number)) < 0.01;
if (!isTaxExclusiveEqual) {
this.addError(
'BR-CO-13',
`Tax exclusive amount (${declaredTaxExclusive.toFixed(2)}) does not match calculation (${expectedTaxExclusive.toFixed(2)})`,
`Tax exclusive amount (${useDecimal ? (declaredTaxExclusive as Decimal).toFixed(2) : (declaredTaxExclusive as number).toFixed(2)}) does not match calculation (${useDecimal ? (expectedTaxExclusive as Decimal).toFixed(2) : (expectedTaxExclusive as number).toFixed(2)})`,
'totalNet',
declaredTaxExclusive,
expectedTaxExclusive
useDecimal ? (declaredTaxExclusive as Decimal).toNumber() : declaredTaxExclusive as number,
useDecimal ? (expectedTaxExclusive as Decimal).toNumber() : expectedTaxExclusive as number
);
}
// BR-CO-14: Invoice total VAT amount = Σ(VAT category tax amount)
const calculatedVAT = this.calculateTotalVAT(invoice);
const declaredVAT = invoice.totalVat || 0;
const calculatedVAT = useDecimal
? this.calculateTotalVATDecimal(invoice)
: this.calculateTotalVAT(invoice);
const declaredVAT = useDecimal
? new Decimal(invoice.totalVat || 0)
: invoice.totalVat || 0;
const isVATEqual = this.currencyCalculator
? this.currencyCalculator.areEqual(calculatedVAT, declaredVAT)
: Math.abs(calculatedVAT - declaredVAT) < 0.01;
const isVATEqual = useDecimal
? this.decimalCalculator!.areEqual(calculatedVAT, declaredVAT)
: this.currencyCalculator
? this.currencyCalculator.areEqual(calculatedVAT as number, declaredVAT as number)
: Math.abs((calculatedVAT as number) - (declaredVAT as number)) < 0.01;
if (!isVATEqual) {
this.addError(
'BR-CO-14',
`Total VAT (${declaredVAT.toFixed(2)}) does not match calculation (${calculatedVAT.toFixed(2)})`,
`Total VAT (${useDecimal ? (declaredVAT as Decimal).toFixed(2) : (declaredVAT as number).toFixed(2)}) does not match calculation (${useDecimal ? (calculatedVAT as Decimal).toFixed(2) : (calculatedVAT as number).toFixed(2)})`,
'totalVat',
declaredVAT,
calculatedVAT
useDecimal ? (declaredVAT as Decimal).toNumber() : declaredVAT as number,
useDecimal ? (calculatedVAT as Decimal).toNumber() : calculatedVAT as number
);
}
// BR-CO-15: Invoice total with VAT = Invoice total without VAT + Invoice total VAT
const expectedGrossTotal = expectedTaxExclusive + calculatedVAT;
const declaredGrossTotal = invoice.totalGross || 0;
const expectedGrossTotal = useDecimal
? (expectedTaxExclusive as Decimal).add(calculatedVAT)
: (expectedTaxExclusive as number) + (calculatedVAT as number);
const declaredGrossTotal = useDecimal
? new Decimal(invoice.totalGross || 0)
: invoice.totalGross || 0;
const isGrossEqual = this.currencyCalculator
? this.currencyCalculator.areEqual(expectedGrossTotal, declaredGrossTotal)
: Math.abs(expectedGrossTotal - declaredGrossTotal) < 0.01;
const isGrossEqual = useDecimal
? this.decimalCalculator!.areEqual(expectedGrossTotal, declaredGrossTotal)
: this.currencyCalculator
? this.currencyCalculator.areEqual(expectedGrossTotal as number, declaredGrossTotal as number)
: Math.abs((expectedGrossTotal as number) - (declaredGrossTotal as number)) < 0.01;
if (!isGrossEqual) {
this.addError(
'BR-CO-15',
`Gross total (${declaredGrossTotal.toFixed(2)}) does not match calculation (${expectedGrossTotal.toFixed(2)})`,
`Gross total (${useDecimal ? (declaredGrossTotal as Decimal).toFixed(2) : (declaredGrossTotal as number).toFixed(2)}) does not match calculation (${useDecimal ? (expectedGrossTotal as Decimal).toFixed(2) : (expectedGrossTotal as number).toFixed(2)})`,
'totalGross',
declaredGrossTotal,
expectedGrossTotal
useDecimal ? (declaredGrossTotal as Decimal).toNumber() : declaredGrossTotal as number,
useDecimal ? (expectedGrossTotal as Decimal).toNumber() : expectedGrossTotal as number
);
}
// BR-CO-16: Amount due for payment = Invoice total with VAT - Paid amount
const paidAmount = invoice.metadata?.paidAmount || 0;
const expectedDueAmount = expectedGrossTotal - paidAmount;
const declaredDueAmount = invoice.metadata?.amountDue || expectedGrossTotal;
const paidAmount = useDecimal
? new Decimal(invoice.metadata?.paidAmount || 0)
: invoice.metadata?.paidAmount || 0;
const expectedDueAmount = useDecimal
? (expectedGrossTotal as Decimal).subtract(paidAmount)
: (expectedGrossTotal as number) - (paidAmount as number);
const declaredDueAmount = useDecimal
? new Decimal(invoice.metadata?.amountDue || (useDecimal ? (expectedGrossTotal as Decimal).toNumber() : expectedGrossTotal))
: invoice.metadata?.amountDue || expectedGrossTotal;
const isDueEqual = this.currencyCalculator
? this.currencyCalculator.areEqual(expectedDueAmount, declaredDueAmount)
: Math.abs(expectedDueAmount - declaredDueAmount) < 0.01;
const isDueEqual = useDecimal
? this.decimalCalculator!.areEqual(expectedDueAmount, declaredDueAmount)
: this.currencyCalculator
? this.currencyCalculator.areEqual(expectedDueAmount as number, declaredDueAmount as number)
: Math.abs((expectedDueAmount as number) - (declaredDueAmount as number)) < 0.01;
if (!isDueEqual) {
this.addError(
'BR-CO-16',
`Amount due (${declaredDueAmount.toFixed(2)}) does not match calculation (${expectedDueAmount.toFixed(2)})`,
`Amount due (${useDecimal ? (declaredDueAmount as Decimal).toFixed(2) : (declaredDueAmount as number).toFixed(2)}) does not match calculation (${useDecimal ? (expectedDueAmount as Decimal).toFixed(2) : (expectedDueAmount as number).toFixed(2)})`,
'amountDue',
declaredDueAmount,
expectedDueAmount
useDecimal ? (declaredDueAmount as Decimal).toNumber() : declaredDueAmount as number,
useDecimal ? (expectedDueAmount as Decimal).toNumber() : expectedDueAmount as number
);
}
}
@@ -220,6 +263,8 @@ export class EN16931BusinessRulesValidator {
* Validate VAT rules
*/
private validateVATRules(invoice: EInvoice): void {
const useDecimal = this.decimalCalculator !== undefined;
// Group items by VAT rate
const vatGroups = this.groupItemsByVAT(invoice.items || []);
@@ -247,11 +292,19 @@ export class EN16931BusinessRulesValidator {
// BR-S-03: VAT category tax amount for standard rated
vatGroups.forEach((group, rate) => {
if (rate > 0) { // Standard rated
const expectedTaxableAmount = group.reduce((sum, item) =>
sum + (item.unitNetPrice * item.unitQuantity), 0
);
const expectedTaxableAmount = useDecimal
? group.reduce((sum, item) => {
const unitPrice = new Decimal(item.unitNetPrice);
const quantity = new Decimal(item.unitQuantity);
return sum.add(unitPrice.multiply(quantity));
}, Decimal.ZERO)
: group.reduce((sum, item) =>
sum + (item.unitNetPrice * item.unitQuantity), 0
);
const expectedTaxAmount = expectedTaxableAmount * (rate / 100);
const expectedTaxAmount = useDecimal
? this.decimalCalculator!.calculateVAT(expectedTaxableAmount, new Decimal(rate))
: (expectedTaxableAmount as number) * (rate / 100);
// Find corresponding breakdown
const breakdown = invoice.taxBreakdown?.find(b =>
@@ -259,9 +312,11 @@ export class EN16931BusinessRulesValidator {
);
if (breakdown) {
const isTaxableEqual = this.currencyCalculator
? this.currencyCalculator.areEqual(breakdown.netAmount, expectedTaxableAmount)
: Math.abs(breakdown.netAmount - expectedTaxableAmount) < 0.01;
const isTaxableEqual = useDecimal
? this.decimalCalculator!.areEqual(expectedTaxableAmount, breakdown.netAmount)
: this.currencyCalculator
? this.currencyCalculator.areEqual(breakdown.netAmount, expectedTaxableAmount as number)
: Math.abs(breakdown.netAmount - (expectedTaxableAmount as number)) < 0.01;
if (!isTaxableEqual) {
this.addError(
@@ -269,13 +324,15 @@ export class EN16931BusinessRulesValidator {
`VAT taxable amount for ${rate}% incorrect`,
'taxBreakdown.netAmount',
breakdown.netAmount,
expectedTaxableAmount
useDecimal ? (expectedTaxableAmount as Decimal).toNumber() : expectedTaxableAmount as number
);
}
const isTaxEqual = this.currencyCalculator
? this.currencyCalculator.areEqual(breakdown.taxAmount, expectedTaxAmount)
: Math.abs(breakdown.taxAmount - expectedTaxAmount) < 0.01;
const isTaxEqual = useDecimal
? this.decimalCalculator!.areEqual(expectedTaxAmount, breakdown.taxAmount)
: this.currencyCalculator
? this.currencyCalculator.areEqual(breakdown.taxAmount, expectedTaxAmount as number)
: Math.abs(breakdown.taxAmount - (expectedTaxAmount as number)) < 0.01;
if (!isTaxEqual) {
this.addError(
@@ -283,7 +340,7 @@ export class EN16931BusinessRulesValidator {
`VAT tax amount for ${rate}% incorrect`,
'taxBreakdown.vatAmount',
breakdown.taxAmount,
expectedTaxAmount
useDecimal ? (expectedTaxAmount as Decimal).toNumber() : expectedTaxAmount as number
);
}
}
@@ -467,6 +524,90 @@ export class EN16931BusinessRulesValidator {
return sum + rounded;
}, 0);
}
/**
* Calculate line total using decimal arithmetic for precision
*/
private calculateLineTotalDecimal(items: TAccountingDocItem[]): Decimal {
let total = Decimal.ZERO;
for (const item of items) {
const unitPrice = new Decimal(item.unitNetPrice || 0);
const quantity = new Decimal(item.unitQuantity || 0);
const lineTotal = unitPrice.multiply(quantity);
total = total.add(this.decimalCalculator!.round(lineTotal));
}
return total;
}
/**
* Calculate document allowances using decimal arithmetic
*/
private calculateDocumentAllowancesDecimal(invoice: EInvoice): Decimal {
if (!invoice.metadata?.allowances) {
return Decimal.ZERO;
}
let total = Decimal.ZERO;
for (const allowance of invoice.metadata.allowances) {
const amount = new Decimal(allowance.amount || 0);
total = total.add(this.decimalCalculator!.round(amount));
}
return total;
}
/**
* Calculate document charges using decimal arithmetic
*/
private calculateDocumentChargesDecimal(invoice: EInvoice): Decimal {
if (!invoice.metadata?.charges) {
return Decimal.ZERO;
}
let total = Decimal.ZERO;
for (const charge of invoice.metadata.charges) {
const amount = new Decimal(charge.amount || 0);
total = total.add(this.decimalCalculator!.round(amount));
}
return total;
}
/**
* Calculate total VAT using decimal arithmetic
*/
private calculateTotalVATDecimal(invoice: EInvoice): Decimal {
let totalVAT = Decimal.ZERO;
// Group items by VAT rate
const vatGroups = new Map<string, Decimal>();
for (const item of invoice.items || []) {
const vatRate = item.vatPercentage || 0;
const rateKey = vatRate.toString();
const unitPrice = new Decimal(item.unitNetPrice || 0);
const quantity = new Decimal(item.unitQuantity || 0);
const lineNet = unitPrice.multiply(quantity);
if (vatGroups.has(rateKey)) {
vatGroups.set(rateKey, vatGroups.get(rateKey)!.add(lineNet));
} else {
vatGroups.set(rateKey, lineNet);
}
}
// Calculate VAT for each group
for (const [rateKey, baseAmount] of vatGroups) {
const rate = new Decimal(rateKey);
const vat = this.decimalCalculator!.calculateVAT(baseAmount, rate);
totalVAT = totalVAT.add(vat);
}
return totalVAT;
}
private calculateDocumentAllowances(invoice: EInvoice): number {
return invoice.metadata?.allowances?.reduce((sum: number, allowance: any) =>

View File

@@ -0,0 +1,579 @@
/**
* Factur-X validator for profile-specific compliance
* Implements validation for MINIMUM, BASIC, EN16931, and EXTENDED profiles
*/
import type { ValidationResult } from './validation.types.js';
import type { EInvoice } from '../../einvoice.js';
/**
* Factur-X Profile definitions
*/
export enum FacturXProfile {
MINIMUM = 'MINIMUM',
BASIC = 'BASIC',
BASIC_WL = 'BASIC_WL', // Basic without lines
EN16931 = 'EN16931',
EXTENDED = 'EXTENDED'
}
/**
* Field cardinality requirements per profile
*/
interface ProfileRequirements {
mandatory: string[];
optional: string[];
forbidden?: string[];
}
/**
* Factur-X Validator
* Validates invoices according to Factur-X profile specifications
*/
export class FacturXValidator {
private static instance: FacturXValidator;
/**
* Profile requirements mapping
*/
private profileRequirements: Record<FacturXProfile, ProfileRequirements> = {
[FacturXProfile.MINIMUM]: {
mandatory: [
'accountingDocId', // BT-1: Invoice number
'issueDate', // BT-2: Invoice issue date
'accountingDocType', // BT-3: Invoice type code
'currency', // BT-5: Invoice currency code
'from.name', // BT-27: Seller name
'from.vatNumber', // BT-31: Seller VAT identifier
'to.name', // BT-44: Buyer name
'totalInvoiceAmount', // BT-112: Invoice total amount with VAT
'totalNetAmount', // BT-109: Invoice total amount without VAT
'totalVatAmount', // BT-110: Invoice total VAT amount
],
optional: []
},
[FacturXProfile.BASIC]: {
mandatory: [
// All MINIMUM fields plus:
'accountingDocId',
'issueDate',
'accountingDocType',
'currency',
'from.name',
'from.vatNumber',
'from.address', // BT-35: Seller postal address
'from.country', // BT-40: Seller country code
'to.name',
'to.address', // BT-50: Buyer postal address
'to.country', // BT-55: Buyer country code
'items', // BG-25: Invoice line items
'items[].name', // BT-153: Item name
'items[].unitQuantity', // BT-129: Invoiced quantity
'items[].unitNetPrice', // BT-146: Item net price
'items[].vatPercentage', // BT-152: Invoiced item VAT rate
'totalInvoiceAmount',
'totalNetAmount',
'totalVatAmount',
'dueDate', // BT-9: Payment due date
],
optional: [
'metadata.buyerReference', // BT-10: Buyer reference
'metadata.purchaseOrderReference', // BT-13: Purchase order reference
'metadata.salesOrderReference', // BT-14: Sales order reference
'metadata.contractReference', // BT-12: Contract reference
'projectReference', // BT-11: Project reference
]
},
[FacturXProfile.BASIC_WL]: {
// Basic without lines - for summary invoices
mandatory: [
'accountingDocId',
'issueDate',
'accountingDocType',
'currency',
'from.name',
'from.vatNumber',
'from.address',
'from.country',
'to.name',
'to.address',
'to.country',
'totalInvoiceAmount',
'totalNetAmount',
'totalVatAmount',
'dueDate',
// No items required
],
optional: [
'metadata.buyerReference',
'metadata.purchaseOrderReference',
'metadata.contractReference',
]
},
[FacturXProfile.EN16931]: {
// Full EN16931 compliance - all mandatory fields from the standard
mandatory: [
// Document level
'accountingDocId',
'issueDate',
'accountingDocType',
'currency',
'metadata.buyerReference',
// Seller information
'from.name',
'from.address',
'from.city',
'from.postalCode',
'from.country',
'from.vatNumber',
// Buyer information
'to.name',
'to.address',
'to.city',
'to.postalCode',
'to.country',
// Line items
'items',
'items[].name',
'items[].unitQuantity',
'items[].unitType',
'items[].unitNetPrice',
'items[].vatPercentage',
// Totals
'totalInvoiceAmount',
'totalNetAmount',
'totalVatAmount',
'dueDate',
],
optional: [
// All other EN16931 fields
'metadata.purchaseOrderReference',
'metadata.salesOrderReference',
'metadata.contractReference',
'metadata.deliveryDate',
'metadata.paymentTerms',
'metadata.paymentMeans',
'to.vatNumber',
'to.legalRegistration',
'items[].articleNumber',
'items[].description',
'paymentAccount',
]
},
[FacturXProfile.EXTENDED]: {
// Extended profile allows all fields
mandatory: [
// Same as EN16931 core
'accountingDocId',
'issueDate',
'accountingDocType',
'currency',
'from.name',
'from.vatNumber',
'to.name',
'totalInvoiceAmount',
],
optional: [
// All fields are allowed in EXTENDED profile
]
}
};
/**
* Singleton pattern for validator instance
*/
public static create(): FacturXValidator {
if (!FacturXValidator.instance) {
FacturXValidator.instance = new FacturXValidator();
}
return FacturXValidator.instance;
}
/**
* Main validation entry point for Factur-X
*/
public validateFacturX(invoice: EInvoice, profile?: FacturXProfile): ValidationResult[] {
const results: ValidationResult[] = [];
// Detect profile if not provided
const detectedProfile = profile || this.detectProfile(invoice);
// Skip if not a Factur-X invoice
if (!detectedProfile) {
return results;
}
// Validate according to profile
results.push(...this.validateProfileRequirements(invoice, detectedProfile));
results.push(...this.validateProfileSpecificRules(invoice, detectedProfile));
// Add profile-specific business rules
if (detectedProfile === FacturXProfile.MINIMUM) {
results.push(...this.validateMinimumProfile(invoice));
} else if (detectedProfile === FacturXProfile.BASIC || detectedProfile === FacturXProfile.BASIC_WL) {
results.push(...this.validateBasicProfile(invoice, detectedProfile));
} else if (detectedProfile === FacturXProfile.EN16931) {
results.push(...this.validateEN16931Profile(invoice));
} else if (detectedProfile === FacturXProfile.EXTENDED) {
results.push(...this.validateExtendedProfile(invoice));
}
return results;
}
/**
* Detect Factur-X profile from invoice metadata
*/
public detectProfile(invoice: EInvoice): FacturXProfile | null {
const profileId = invoice.metadata?.profileId || '';
const customizationId = invoice.metadata?.customizationId || '';
const format = invoice.metadata?.format;
// Check if it's a Factur-X invoice
if (!format?.includes('facturx') && !profileId.includes('facturx') &&
!customizationId.includes('facturx') && !profileId.includes('zugferd')) {
return null;
}
// Detect specific profile
const profileLower = profileId.toLowerCase();
const customLower = customizationId.toLowerCase();
if (profileLower.includes('minimum') || customLower.includes('minimum')) {
return FacturXProfile.MINIMUM;
} else if (profileLower.includes('basic_wl') || customLower.includes('basicwl')) {
return FacturXProfile.BASIC_WL;
} else if (profileLower.includes('basic') || customLower.includes('basic')) {
return FacturXProfile.BASIC;
} else if (profileLower.includes('en16931') || customLower.includes('en16931') ||
profileLower.includes('comfort') || customLower.includes('comfort')) {
return FacturXProfile.EN16931;
} else if (profileLower.includes('extended') || customLower.includes('extended')) {
return FacturXProfile.EXTENDED;
}
// Default to BASIC if format is Factur-X but profile unclear
return FacturXProfile.BASIC;
}
/**
* Validate field requirements for a specific profile
*/
private validateProfileRequirements(invoice: EInvoice, profile: FacturXProfile): ValidationResult[] {
const results: ValidationResult[] = [];
const requirements = this.profileRequirements[profile];
// Check mandatory fields
for (const field of requirements.mandatory) {
const value = this.getFieldValue(invoice, field);
if (value === undefined || value === null || value === '') {
results.push({
ruleId: `FX-${profile}-M01`,
severity: 'error',
message: `Field '${field}' is mandatory for Factur-X ${profile} profile`,
field: field,
source: 'FACTURX'
});
}
}
// Check forbidden fields (if any)
if (requirements.forbidden) {
for (const field of requirements.forbidden) {
const value = this.getFieldValue(invoice, field);
if (value !== undefined && value !== null) {
results.push({
ruleId: `FX-${profile}-F01`,
severity: 'error',
message: `Field '${field}' is not allowed in Factur-X ${profile} profile`,
field: field,
value: value,
source: 'FACTURX'
});
}
}
}
return results;
}
/**
* Get field value from invoice using dot notation
*/
private getFieldValue(invoice: any, fieldPath: string): any {
// Handle special calculated fields
if (fieldPath === 'totalInvoiceAmount') {
return invoice.totalGross || invoice.totalInvoiceAmount;
}
if (fieldPath === 'totalNetAmount') {
return invoice.totalNet || invoice.totalNetAmount;
}
if (fieldPath === 'totalVatAmount') {
return invoice.totalVat || invoice.totalVatAmount;
}
if (fieldPath === 'dueDate') {
// Check for dueInDays which is used in EInvoice
if (invoice.dueInDays !== undefined && invoice.dueInDays !== null) {
return true; // Has payment terms
}
return invoice.dueDate;
}
const parts = fieldPath.split('.');
let value = invoice;
for (const part of parts) {
if (part.includes('[')) {
// Array field like items[]
const fieldName = part.substring(0, part.indexOf('['));
const arrayField = part.substring(part.indexOf('[') + 1, part.indexOf(']'));
if (!value[fieldName] || !Array.isArray(value[fieldName])) {
return undefined;
}
if (arrayField === '') {
// Check if array exists and has items
return value[fieldName].length > 0 ? value[fieldName] : undefined;
} else {
// Check specific field in array items
return value[fieldName].every((item: any) => item[arrayField] !== undefined);
}
} else {
value = value?.[part];
}
}
return value;
}
/**
* Profile-specific validation rules
*/
private validateProfileSpecificRules(invoice: EInvoice, profile: FacturXProfile): ValidationResult[] {
const results: ValidationResult[] = [];
// Validate according to profile level
switch (profile) {
case FacturXProfile.MINIMUM:
// MINIMUM requires at least gross amounts
// Check both calculated totals and direct properties (for test compatibility)
const totalGross = invoice.totalGross || (invoice as any).totalInvoiceAmount;
if (!totalGross || totalGross <= 0) {
results.push({
ruleId: 'FX-MIN-01',
severity: 'error',
message: 'MINIMUM profile requires positive total invoice amount',
field: 'totalInvoiceAmount',
value: totalGross,
source: 'FACTURX'
});
}
break;
case FacturXProfile.BASIC:
case FacturXProfile.BASIC_WL:
// BASIC requires VAT breakdown
const totalVat = invoice.totalVat;
if (!invoice.metadata?.extensions?.taxDetails && totalVat > 0) {
results.push({
ruleId: 'FX-BAS-01',
severity: 'warning',
message: 'BASIC profile should include VAT breakdown when VAT is present',
field: 'metadata.extensions.taxDetails',
source: 'FACTURX'
});
}
break;
case FacturXProfile.EN16931:
// EN16931 requires full compliance - additional checks handled by EN16931 validator
if (!invoice.metadata?.buyerReference && !invoice.metadata?.extensions?.purchaseOrderReference) {
results.push({
ruleId: 'FX-EN-01',
severity: 'error',
message: 'EN16931 profile requires either buyer reference or purchase order reference',
field: 'metadata.buyerReference',
source: 'FACTURX'
});
}
break;
}
return results;
}
/**
* Validate MINIMUM profile specific rules
*/
private validateMinimumProfile(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
// MINIMUM profile allows only essential fields
// Check that complex structures are not present
if (invoice.items && invoice.items.length > 0) {
// Lines are optional but if present must be minimal
invoice.items.forEach((item, index) => {
if ((item as any).allowances || (item as any).charges) {
results.push({
ruleId: 'FX-MIN-02',
severity: 'warning',
message: `Line ${index + 1}: MINIMUM profile should not include line allowances/charges`,
field: `items[${index}]`,
source: 'FACTURX'
});
}
});
}
return results;
}
/**
* Validate BASIC profile specific rules
*/
private validateBasicProfile(invoice: EInvoice, profile: FacturXProfile): ValidationResult[] {
const results: ValidationResult[] = [];
// BASIC requires line items (except BASIC_WL)
// Only check for line items in BASIC profile, not BASIC_WL
if (profile === FacturXProfile.BASIC) {
if (!invoice.items || invoice.items.length === 0) {
results.push({
ruleId: 'FX-BAS-02',
severity: 'error',
message: 'BASIC profile requires at least one invoice line item',
field: 'items',
source: 'FACTURX'
});
}
}
// Payment information should be present
if (!invoice.dueInDays && invoice.dueInDays !== 0) {
results.push({
ruleId: 'FX-BAS-03',
severity: 'warning',
message: 'BASIC profile should include payment terms (due in days)',
field: 'dueInDays',
source: 'FACTURX'
});
}
return results;
}
/**
* Validate EN16931 profile specific rules
*/
private validateEN16931Profile(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
// EN16931 requires complete address information
const fromAny = invoice.from as any;
const toAny = invoice.to as any;
if (!fromAny?.city || !fromAny?.postalCode) {
results.push({
ruleId: 'FX-EN-02',
severity: 'error',
message: 'EN16931 profile requires complete seller address including city and postal code',
field: 'from.address',
source: 'FACTURX'
});
}
if (!toAny?.city || !toAny?.postalCode) {
results.push({
ruleId: 'FX-EN-03',
severity: 'error',
message: 'EN16931 profile requires complete buyer address including city and postal code',
field: 'to.address',
source: 'FACTURX'
});
}
// Line items must have unit type
if (invoice.items) {
invoice.items.forEach((item, index) => {
if (!item.unitType) {
results.push({
ruleId: 'FX-EN-04',
severity: 'error',
message: `Line ${index + 1}: EN16931 profile requires unit of measure`,
field: `items[${index}].unitType`,
source: 'FACTURX'
});
}
});
}
return results;
}
/**
* Validate EXTENDED profile specific rules
*/
private validateExtendedProfile(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
// EXTENDED profile is most permissive - mainly check for data consistency
if (invoice.metadata?.extensions) {
// Extended profile can include additional structured data
// Validate that extended data is well-formed
const extensions = invoice.metadata.extensions;
if (extensions.attachments && Array.isArray(extensions.attachments)) {
extensions.attachments.forEach((attachment: any, index: number) => {
if (!attachment.filename || !attachment.mimeType) {
results.push({
ruleId: 'FX-EXT-01',
severity: 'warning',
message: `Attachment ${index + 1}: Should include filename and MIME type`,
field: `metadata.extensions.attachments[${index}]`,
source: 'FACTURX'
});
}
});
}
}
return results;
}
/**
* Get profile display name
*/
public getProfileDisplayName(profile: FacturXProfile): string {
const names: Record<FacturXProfile, string> = {
[FacturXProfile.MINIMUM]: 'Factur-X MINIMUM',
[FacturXProfile.BASIC]: 'Factur-X BASIC',
[FacturXProfile.BASIC_WL]: 'Factur-X BASIC WL',
[FacturXProfile.EN16931]: 'Factur-X EN16931',
[FacturXProfile.EXTENDED]: 'Factur-X EXTENDED'
};
return names[profile];
}
/**
* Get profile compliance level (for reporting)
*/
public getProfileComplianceLevel(profile: FacturXProfile): number {
const levels: Record<FacturXProfile, number> = {
[FacturXProfile.MINIMUM]: 1,
[FacturXProfile.BASIC_WL]: 2,
[FacturXProfile.BASIC]: 3,
[FacturXProfile.EN16931]: 4,
[FacturXProfile.EXTENDED]: 5
};
return levels[profile];
}
}

View File

@@ -0,0 +1,405 @@
/**
* Main integrated validator combining all validation capabilities
* Orchestrates TypeScript validators, Schematron, and profile-specific rules
*/
import { IntegratedValidator } from './schematron.integration.js';
import { XRechnungValidator } from './xrechnung.validator.js';
import { PeppolValidator } from './peppol.validator.js';
import { FacturXValidator } from './facturx.validator.js';
import { EN16931BusinessRulesValidator } from './en16931.business-rules.validator.js';
import { CodeListValidator } from './codelist.validator.js';
import type { ValidationResult, ValidationOptions, ValidationReport } from './validation.types.js';
import type { EInvoice } from '../../einvoice.js';
/**
* Main validator that combines all validation capabilities
*/
export class MainValidator {
private integratedValidator: IntegratedValidator;
private xrechnungValidator: XRechnungValidator;
private peppolValidator: PeppolValidator;
private facturxValidator: FacturXValidator;
private businessRulesValidator: EN16931BusinessRulesValidator;
private codeListValidator: CodeListValidator;
private schematronEnabled: boolean = false;
constructor() {
this.integratedValidator = new IntegratedValidator();
this.xrechnungValidator = XRechnungValidator.create();
this.peppolValidator = PeppolValidator.create();
this.facturxValidator = FacturXValidator.create();
this.businessRulesValidator = new EN16931BusinessRulesValidator();
this.codeListValidator = new CodeListValidator();
}
/**
* Initialize Schematron validation for better coverage
*/
public async initializeSchematron(
profile?: 'EN16931' | 'PEPPOL' | 'XRECHNUNG'
): Promise<void> {
try {
// Check available Schematron files
const available = await this.integratedValidator.getAvailableSchematron();
if (available.length === 0) {
console.warn('No Schematron files available. Run: npm run download-schematron');
return;
}
// Load appropriate Schematron based on profile
const standard = profile || 'EN16931';
const format = 'UBL'; // Default to UBL, can be made configurable
await this.integratedValidator.loadSchematron(
standard === 'XRECHNUNG' ? 'EN16931' : standard, // XRechnung uses EN16931 as base
format
);
this.schematronEnabled = true;
console.log(`Schematron validation enabled for ${standard} ${format}`);
} catch (error) {
console.warn(`Failed to initialize Schematron: ${error.message}`);
}
}
/**
* Validate an invoice with all available validators
*/
public async validate(
invoice: EInvoice,
xmlContent?: string,
options: ValidationOptions = {}
): Promise<ValidationReport> {
const startTime = Date.now();
const results: ValidationResult[] = [];
// Detect profile from invoice
const profile = this.detectProfile(invoice);
const mergedOptions: ValidationOptions = {
...options,
profile: profile as ValidationOptions['profile']
};
// Run base validators
if (options.checkCodeLists !== false) {
results.push(...this.codeListValidator.validate(invoice));
}
results.push(...this.businessRulesValidator.validate(invoice, mergedOptions));
// Run XRechnung-specific validation if applicable
if (this.isXRechnungInvoice(invoice)) {
const xrResults = this.xrechnungValidator.validateXRechnung(invoice);
results.push(...xrResults);
}
// Run PEPPOL-specific validation if applicable
if (this.isPeppolInvoice(invoice)) {
const peppolResults = this.peppolValidator.validatePeppol(invoice);
results.push(...peppolResults);
}
// Run Factur-X specific validation if applicable
if (this.isFacturXInvoice(invoice)) {
const facturxResults = this.facturxValidator.validateFacturX(invoice);
results.push(...facturxResults);
}
// Run Schematron validation if available and XML is provided
if (this.schematronEnabled && xmlContent) {
try {
const schematronReport = await this.integratedValidator.validate(
invoice,
xmlContent,
mergedOptions
);
// Extract only Schematron-specific results to avoid duplication
const schematronResults = schematronReport.results.filter(
r => r.source === 'SCHEMATRON'
);
results.push(...schematronResults);
} catch (error) {
console.warn(`Schematron validation error: ${error.message}`);
}
}
// Remove duplicates (same rule + same field)
const uniqueResults = this.deduplicateResults(results);
// Calculate statistics
const errorCount = uniqueResults.filter(r => r.severity === 'error').length;
const warningCount = uniqueResults.filter(r => r.severity === 'warning').length;
const infoCount = uniqueResults.filter(r => r.severity === 'info').length;
// Estimate coverage
const totalRules = this.estimateTotalRules(profile);
const rulesChecked = new Set(uniqueResults.map(r => r.ruleId)).size;
const coverage = totalRules > 0 ? (rulesChecked / totalRules) * 100 : 0;
return {
valid: errorCount === 0,
profile: profile || 'EN16931',
timestamp: new Date().toISOString(),
validatorVersion: '2.0.0',
rulesetVersion: '1.3.14',
results: uniqueResults,
errorCount,
warningCount,
infoCount,
rulesChecked,
rulesTotal: totalRules,
coverage,
validationTime: Date.now() - startTime,
documentId: invoice.accountingDocId,
documentType: invoice.accountingDocType,
format: this.detectFormat(xmlContent)
} as ValidationReport & { schematronEnabled: boolean };
}
/**
* Detect profile from invoice metadata
*/
private detectProfile(invoice: EInvoice): string {
const profileId = invoice.metadata?.profileId || '';
const customizationId = invoice.metadata?.customizationId || '';
if (profileId.includes('xrechnung') || customizationId.includes('xrechnung')) {
return 'XRECHNUNG_3.0';
}
if (profileId.includes('peppol') || customizationId.includes('peppol') ||
profileId.includes('urn:fdc:peppol.eu')) {
return 'PEPPOL_BIS_3.0';
}
if (profileId.includes('facturx') || customizationId.includes('facturx') ||
profileId.includes('zugferd')) {
// Try to detect specific Factur-X profile
const facturxProfile = this.facturxValidator.detectProfile(invoice);
if (facturxProfile) {
return `FACTURX_${facturxProfile}`;
}
return 'FACTURX_EN16931';
}
return 'EN16931';
}
/**
* Check if invoice is XRechnung
*/
private isXRechnungInvoice(invoice: EInvoice): boolean {
const profileId = invoice.metadata?.profileId || '';
const customizationId = invoice.metadata?.customizationId || '';
const xrechnungProfiles = [
'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung',
'urn:cen.eu:en16931:2017#conformant#urn:xeinkauf.de:kosit:xrechnung',
'xrechnung'
];
return xrechnungProfiles.some(profile =>
profileId.toLowerCase().includes(profile.toLowerCase()) ||
customizationId.toLowerCase().includes(profile.toLowerCase())
);
}
/**
* Check if invoice is PEPPOL
*/
private isPeppolInvoice(invoice: EInvoice): boolean {
const profileId = invoice.metadata?.profileId || '';
const customizationId = invoice.metadata?.customizationId || '';
const peppolProfiles = [
'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0',
'peppol-bis-3',
'peppol'
];
return peppolProfiles.some(profile =>
profileId.toLowerCase().includes(profile.toLowerCase()) ||
customizationId.toLowerCase().includes(profile.toLowerCase())
);
}
/**
* Check if invoice is Factur-X
*/
private isFacturXInvoice(invoice: EInvoice): boolean {
const profileId = invoice.metadata?.profileId || '';
const customizationId = invoice.metadata?.customizationId || '';
const format = invoice.metadata?.format;
return format?.includes('facturx') ||
profileId.toLowerCase().includes('facturx') ||
customizationId.toLowerCase().includes('facturx') ||
profileId.toLowerCase().includes('zugferd') ||
customizationId.toLowerCase().includes('zugferd');
}
/**
* Detect format from XML content
*/
private detectFormat(xmlContent?: string): 'UBL' | 'CII' | undefined {
if (!xmlContent) return undefined;
if (xmlContent.includes('urn:oasis:names:specification:ubl:schema:xsd:Invoice-2')) {
return 'UBL';
} else if (xmlContent.includes('urn:un:unece:uncefact:data:standard:CrossIndustryInvoice')) {
return 'CII';
}
return undefined;
}
/**
* Remove duplicate validation results
*/
private deduplicateResults(results: ValidationResult[]): ValidationResult[] {
const seen = new Set<string>();
const unique: ValidationResult[] = [];
for (const result of results) {
const key = `${result.ruleId}|${result.field || ''}|${result.message}`;
if (!seen.has(key)) {
seen.add(key);
unique.push(result);
}
}
return unique;
}
/**
* Estimate total rules for coverage calculation
*/
private estimateTotalRules(profile?: string): number {
const ruleCounts: Record<string, number> = {
EN16931: 150,
'PEPPOL_BIS_3.0': 250,
'XRECHNUNG_3.0': 280,
FACTURX_BASIC: 100,
FACTURX_EN16931: 150
};
return ruleCounts[profile || 'EN16931'] || 150;
}
/**
* Validate with automatic format and profile detection
*/
public async validateAuto(
invoice: EInvoice,
xmlContent?: string
): Promise<ValidationReport> {
// Auto-detect profile
const profile = this.detectProfile(invoice);
// Initialize Schematron if not already done
if (!this.schematronEnabled && xmlContent) {
await this.initializeSchematron(
profile.startsWith('XRECHNUNG') ? 'XRECHNUNG' :
profile.startsWith('PEPPOL') ? 'PEPPOL' : 'EN16931'
);
}
return this.validate(invoice, xmlContent, {
checkCalculations: true,
checkVAT: true,
checkCodeLists: true,
strictMode: profile.includes('XRECHNUNG') // Strict for XRechnung
});
}
/**
* Get validation capabilities
*/
public getCapabilities(): {
schematron: boolean;
xrechnung: boolean;
peppol: boolean;
facturx: boolean;
calculations: boolean;
codeLists: boolean;
} {
return {
schematron: this.schematronEnabled,
xrechnung: true,
peppol: true,
facturx: true,
calculations: true,
codeLists: true
};
}
/**
* Format validation report as text
*/
public formatReport(report: ValidationReport): string {
const lines: string[] = [];
lines.push('=== Validation Report ===');
lines.push(`Profile: ${report.profile}`);
lines.push(`Valid: ${report.valid ? '✅' : '❌'}`);
lines.push(`Timestamp: ${report.timestamp}`);
lines.push('');
if (report.errorCount > 0) {
lines.push(`Errors: ${report.errorCount}`);
report.results
.filter(r => r.severity === 'error')
.forEach(r => {
lines.push(` ❌ [${r.ruleId}] ${r.message}`);
if (r.field) lines.push(` Field: ${r.field}`);
});
lines.push('');
}
if (report.warningCount > 0) {
lines.push(`Warnings: ${report.warningCount}`);
report.results
.filter(r => r.severity === 'warning')
.forEach(r => {
lines.push(` ⚠️ [${r.ruleId}] ${r.message}`);
if (r.field) lines.push(` Field: ${r.field}`);
});
lines.push('');
}
lines.push('Statistics:');
lines.push(` Rules checked: ${report.rulesChecked}/${report.rulesTotal}`);
lines.push(` Coverage: ${report.coverage.toFixed(1)}%`);
lines.push(` Validation time: ${report.validationTime}ms`);
if ((report as any).schematronEnabled) {
lines.push(' Schematron: ✅ Enabled');
}
return lines.join('\n');
}
}
/**
* Create a pre-configured validator instance
*/
export async function createValidator(
options: {
profile?: 'EN16931' | 'PEPPOL' | 'XRECHNUNG';
enableSchematron?: boolean;
} = {}
): Promise<MainValidator> {
const validator = new MainValidator();
if (options.enableSchematron !== false) {
await validator.initializeSchematron(options.profile);
}
return validator;
}
// Export for convenience
export type { ValidationReport, ValidationResult, ValidationOptions } from './validation.types.js';

View File

@@ -0,0 +1,589 @@
/**
* PEPPOL BIS 3.0 validator for compliance with PEPPOL e-invoice specifications
* Implements PEPPOL-specific validation rules on top of EN16931
*/
import type { ValidationResult } from './validation.types.js';
import type { EInvoice } from '../../einvoice.js';
/**
* PEPPOL BIS 3.0 Validator
* Implements PEPPOL-specific validation rules and constraints
*/
export class PeppolValidator {
private static instance: PeppolValidator;
/**
* Singleton pattern for validator instance
*/
public static create(): PeppolValidator {
if (!PeppolValidator.instance) {
PeppolValidator.instance = new PeppolValidator();
}
return PeppolValidator.instance;
}
/**
* Main validation entry point for PEPPOL
*/
public validatePeppol(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
// Check if this is a PEPPOL invoice
if (!this.isPeppolInvoice(invoice)) {
return results; // Not a PEPPOL invoice, skip validation
}
// Run all PEPPOL validations
results.push(...this.validateEndpointId(invoice));
results.push(...this.validateDocumentTypeId(invoice));
results.push(...this.validateProcessId(invoice));
results.push(...this.validatePartyIdentification(invoice));
results.push(...this.validatePeppolBusinessRules(invoice));
results.push(...this.validateSchemeIds(invoice));
results.push(...this.validateTransportProtocol(invoice));
return results;
}
/**
* Check if invoice is PEPPOL
*/
private isPeppolInvoice(invoice: EInvoice): boolean {
const profileId = invoice.metadata?.profileId || '';
const customizationId = invoice.metadata?.customizationId || '';
const peppolProfiles = [
'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0',
'peppol-bis-3',
'peppol'
];
return peppolProfiles.some(profile =>
profileId.toLowerCase().includes(profile.toLowerCase()) ||
customizationId.toLowerCase().includes(profile.toLowerCase())
);
}
/**
* Validate Endpoint ID format (0088:xxxxxxxxx or other schemes)
* PEPPOL-T001, PEPPOL-T002
*/
private validateEndpointId(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
// Check seller endpoint ID
const sellerEndpointId = invoice.metadata?.extensions?.sellerEndpointId ||
invoice.metadata?.extensions?.peppolSellerEndpoint;
if (sellerEndpointId) {
if (!this.isValidEndpointId(sellerEndpointId)) {
results.push({
ruleId: 'PEPPOL-T001',
severity: 'error',
message: 'Invalid seller endpoint ID format. Expected format: scheme:identifier (e.g., 0088:1234567890128)',
field: 'metadata.extensions.sellerEndpointId',
value: sellerEndpointId,
source: 'PEPPOL'
});
}
} else if (this.isPeppolB2G(invoice)) {
// Endpoint ID is mandatory for B2G
results.push({
ruleId: 'PEPPOL-T001',
severity: 'error',
message: 'Seller endpoint ID is mandatory for PEPPOL B2G invoices',
field: 'metadata.extensions.sellerEndpointId',
source: 'PEPPOL'
});
}
// Check buyer endpoint ID
const buyerEndpointId = invoice.metadata?.extensions?.buyerEndpointId ||
invoice.metadata?.extensions?.peppolBuyerEndpoint;
if (buyerEndpointId) {
if (!this.isValidEndpointId(buyerEndpointId)) {
results.push({
ruleId: 'PEPPOL-T002',
severity: 'error',
message: 'Invalid buyer endpoint ID format. Expected format: scheme:identifier (e.g., 0088:1234567890128)',
field: 'metadata.extensions.buyerEndpointId',
value: buyerEndpointId,
source: 'PEPPOL'
});
}
} else if (this.isPeppolB2G(invoice)) {
// Endpoint ID is mandatory for B2G
results.push({
ruleId: 'PEPPOL-T002',
severity: 'error',
message: 'Buyer endpoint ID is mandatory for PEPPOL B2G invoices',
field: 'metadata.extensions.buyerEndpointId',
source: 'PEPPOL'
});
}
return results;
}
/**
* Validate endpoint ID format
*/
private isValidEndpointId(endpointId: string): boolean {
// PEPPOL endpoint ID format: scheme:identifier
// Common schemes: 0088 (GLN), 0192 (Norwegian org), 9906 (IT VAT), etc.
const endpointPattern = /^[0-9]{4}:[A-Za-z0-9\-._]+$/;
// Special validation for GLN (0088)
if (endpointId.startsWith('0088:')) {
const gln = endpointId.substring(5);
// GLN should be 13 digits
if (!/^\d{13}$/.test(gln)) {
return false;
}
// Validate GLN check digit
return this.validateGLNCheckDigit(gln);
}
return endpointPattern.test(endpointId);
}
/**
* Validate GLN check digit using modulo 10
*/
private validateGLNCheckDigit(gln: string): boolean {
if (gln.length !== 13) return false;
let sum = 0;
for (let i = 0; i < 12; i++) {
const digit = parseInt(gln[i], 10);
sum += digit * (i % 2 === 0 ? 1 : 3);
}
const checkDigit = (10 - (sum % 10)) % 10;
return checkDigit === parseInt(gln[12], 10);
}
/**
* Validate Document Type ID
* PEPPOL-T003
*/
private validateDocumentTypeId(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
const documentTypeId = invoice.metadata?.extensions?.documentTypeId ||
invoice.metadata?.extensions?.peppolDocumentType;
if (!documentTypeId && this.isPeppolB2G(invoice)) {
results.push({
ruleId: 'PEPPOL-T003',
severity: 'error',
message: 'Document type ID is mandatory for PEPPOL invoices',
field: 'metadata.extensions.documentTypeId',
source: 'PEPPOL'
});
} else if (documentTypeId) {
// Validate against known PEPPOL document types
const validDocumentTypes = [
'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',
'urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2::CreditNote##urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0::2.1',
// Add more valid document types as needed
];
if (!validDocumentTypes.some(type => documentTypeId.includes(type))) {
results.push({
ruleId: 'PEPPOL-T003',
severity: 'warning',
message: 'Document type ID may not be a valid PEPPOL document type',
field: 'metadata.extensions.documentTypeId',
value: documentTypeId,
source: 'PEPPOL'
});
}
}
return results;
}
/**
* Validate Process ID
* PEPPOL-T004
*/
private validateProcessId(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
const processId = invoice.metadata?.extensions?.processId ||
invoice.metadata?.extensions?.peppolProcessId;
if (!processId && this.isPeppolB2G(invoice)) {
results.push({
ruleId: 'PEPPOL-T004',
severity: 'error',
message: 'Process ID is mandatory for PEPPOL invoices',
field: 'metadata.extensions.processId',
source: 'PEPPOL'
});
} else if (processId) {
// Validate against known PEPPOL processes
const validProcessIds = [
'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0',
'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
// Legacy process IDs
'urn:www.cenbii.eu:profile:bii05:ver2.0',
'urn:www.cenbii.eu:profile:bii04:ver2.0'
];
if (!validProcessIds.includes(processId)) {
results.push({
ruleId: 'PEPPOL-T004',
severity: 'warning',
message: 'Process ID may not be a valid PEPPOL process',
field: 'metadata.extensions.processId',
value: processId,
source: 'PEPPOL'
});
}
}
return results;
}
/**
* Validate Party Identification Schemes
* PEPPOL-T005, PEPPOL-T006
*/
private validatePartyIdentification(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
// Validate seller party identification
if (invoice.from?.type === 'company') {
const company = invoice.from as any;
const partyId = company.registrationDetails?.peppolPartyId ||
company.registrationDetails?.partyIdentification;
if (partyId && partyId.schemeId) {
if (!this.isValidSchemeId(partyId.schemeId)) {
results.push({
ruleId: 'PEPPOL-T005',
severity: 'warning',
message: 'Seller party identification scheme may not be valid',
field: 'from.registrationDetails.partyIdentification.schemeId',
value: partyId.schemeId,
source: 'PEPPOL'
});
}
}
}
// Validate buyer party identification
const buyerPartyId = invoice.metadata?.extensions?.buyerPartyId;
if (buyerPartyId && buyerPartyId.schemeId) {
if (!this.isValidSchemeId(buyerPartyId.schemeId)) {
results.push({
ruleId: 'PEPPOL-T006',
severity: 'warning',
message: 'Buyer party identification scheme may not be valid',
field: 'metadata.extensions.buyerPartyId.schemeId',
value: buyerPartyId.schemeId,
source: 'PEPPOL'
});
}
}
return results;
}
/**
* Validate scheme IDs against PEPPOL code list
*/
private isValidSchemeId(schemeId: string): boolean {
// PEPPOL Party Identifier Scheme (subset of ISO 6523 ICD list)
const validSchemes = [
'0002', // System Information et Repertoire des Entreprise et des Etablissements (SIRENE)
'0007', // Organisationsnummer (Swedish legal entities)
'0009', // SIRET
'0037', // LY-tunnus (Finnish business ID)
'0060', // DUNS number
'0088', // EAN Location Code (GLN)
'0096', // VIOC (Danish CVR)
'0097', // Danish Ministry of the Interior and Health
'0106', // Netherlands Chamber of Commerce
'0130', // Direktoratet for forvaltning og IKT (DIFI)
'0135', // IT:SIA
'0142', // IT:SECETI
'0184', // Danish CVR
'0190', // Dutch Originator's Identification Number
'0191', // Centre of Registers and Information Systems of the Ministry of Justice (Estonia)
'0192', // Norwegian Legal Entity
'0193', // UBL.BE party identifier
'0195', // Singapore UEN
'0196', // Kennitala (Iceland)
'0198', // ERSTORG
'0199', // Legal Entity Identifier (LEI)
'0200', // Legal entity code (Lithuania)
'0201', // CODICE UNIVOCO UNITÀ ORGANIZZATIVA
'0204', // German Leitweg-ID
'0208', // Belgian enterprise number
'0209', // GS1 identification keys
'0210', // CODICE FISCALE
'0211', // PARTITA IVA
'0212', // Finnish Organization Number
'0213', // Finnish VAT number
'9901', // Danish CVR
'9902', // Danish SE
'9904', // German VAT number
'9905', // German Leitweg ID
'9906', // IT:VAT
'9907', // IT:CF
'9910', // HU:VAT
'9914', // AT:VAT
'9915', // AT:GOV
'9917', // Netherlands OIN
'9918', // IS:KT
'9919', // IS company code
'9920', // ES:VAT
'9922', // AD:VAT
'9923', // AL:VAT
'9924', // BA:VAT
'9925', // BE:VAT
'9926', // BG:VAT
'9927', // CH:VAT
'9928', // CY:VAT
'9929', // CZ:VAT
'9930', // DE:VAT
'9931', // EE:VAT
'9932', // GB:VAT
'9933', // GR:VAT
'9934', // HR:VAT
'9935', // IE:VAT
'9936', // LI:VAT
'9937', // LT:VAT
'9938', // LU:VAT
'9939', // LV:VAT
'9940', // MC:VAT
'9941', // ME:VAT
'9942', // MK:VAT
'9943', // MT:VAT
'9944', // NL:VAT
'9945', // PL:VAT
'9946', // PT:VAT
'9947', // RO:VAT
'9948', // RS:VAT
'9949', // SI:VAT
'9950', // SK:VAT
'9951', // SM:VAT
'9952', // TR:VAT
'9953', // VA:VAT
'9955', // SE:VAT
'9956', // BE:CBE
'9957', // FR:VAT
'9958', // German Leitweg ID
];
return validSchemes.includes(schemeId);
}
/**
* Validate PEPPOL-specific business rules
*/
private validatePeppolBusinessRules(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
// PEPPOL-B-01: Invoice must have a buyer reference or purchase order reference
const purchaseOrderRef = invoice.metadata?.extensions?.purchaseOrderReference;
if (!invoice.metadata?.buyerReference && !purchaseOrderRef) {
results.push({
ruleId: 'PEPPOL-B-01',
severity: 'error',
message: 'Invoice must have either a buyer reference (BT-10) or purchase order reference (BT-13)',
field: 'metadata.buyerReference',
source: 'PEPPOL'
});
}
// PEPPOL-B-02: Seller electronic address is mandatory
const sellerEmail = invoice.from?.type === 'company' ?
(invoice.from as any).contact?.email :
(invoice.from as any)?.email;
if (!sellerEmail) {
results.push({
ruleId: 'PEPPOL-B-02',
severity: 'warning',
message: 'Seller electronic address (email) is recommended for PEPPOL invoices',
field: 'from.contact.email',
source: 'PEPPOL'
});
}
// PEPPOL-B-03: Item standard identifier
if (invoice.items && invoice.items.length > 0) {
invoice.items.forEach((item, index) => {
const itemId = (item as any).standardItemIdentification;
if (!itemId) {
results.push({
ruleId: 'PEPPOL-B-03',
severity: 'info',
message: `Item ${index + 1} should have a standard item identification (GTIN, EAN, etc.)`,
field: `items[${index}].standardItemIdentification`,
source: 'PEPPOL'
});
} else if (itemId.schemeId === '0160' && !this.isValidGTIN(itemId.id)) {
// Validate GTIN if scheme is 0160
results.push({
ruleId: 'PEPPOL-B-03',
severity: 'error',
message: `Item ${index + 1} has invalid GTIN`,
field: `items[${index}].standardItemIdentification.id`,
value: itemId.id,
source: 'PEPPOL'
});
}
});
}
// PEPPOL-B-04: Payment means code must be from UNCL4461
const paymentMeansCode = invoice.metadata?.extensions?.paymentMeans?.paymentMeansCode;
if (paymentMeansCode) {
const validPaymentMeans = [
'1', '2', '3', '4', '5', '6', '7', '8', '9', '10',
'11', '12', '13', '14', '15', '16', '17', '18', '19', '20',
'21', '22', '23', '24', '25', '26', '27', '28', '29', '30',
'31', '32', '33', '34', '35', '36', '37', '38', '39', '40',
'41', '42', '43', '44', '45', '46', '47', '48', '49', '50',
'51', '52', '53', '54', '55', '56', '57', '58', '59', '60',
'61', '62', '63', '64', '65', '66', '67', '68', '70', '74',
'75', '76', '77', '78', '91', '92', '93', '94', '95', '96', '97', 'ZZZ'
];
if (!validPaymentMeans.includes(paymentMeansCode)) {
results.push({
ruleId: 'PEPPOL-B-04',
severity: 'error',
message: 'Payment means code must be from UNCL4461 code list',
field: 'metadata.extensions.paymentMeans.paymentMeansCode',
value: paymentMeansCode,
source: 'PEPPOL'
});
}
}
return results;
}
/**
* Validate GTIN (Global Trade Item Number)
*/
private isValidGTIN(gtin: string): boolean {
// GTIN can be 8, 12, 13, or 14 digits
if (!/^(\d{8}|\d{12}|\d{13}|\d{14})$/.test(gtin)) {
return false;
}
// Validate check digit
const digits = gtin.split('').map(d => parseInt(d, 10));
const checkDigit = digits[digits.length - 1];
let sum = 0;
for (let i = digits.length - 2; i >= 0; i--) {
const multiplier = ((digits.length - 2 - i) % 2 === 0) ? 3 : 1;
sum += digits[i] * multiplier;
}
const calculatedCheck = (10 - (sum % 10)) % 10;
return calculatedCheck === checkDigit;
}
/**
* Validate scheme IDs used in the invoice
*/
private validateSchemeIds(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
// Check tax scheme ID
const taxSchemeId = invoice.metadata?.extensions?.taxDetails?.[0]?.taxScheme?.id;
if (taxSchemeId && taxSchemeId !== 'VAT') {
results.push({
ruleId: 'PEPPOL-S-01',
severity: 'warning',
message: 'Tax scheme ID should be "VAT" for PEPPOL invoices',
field: 'metadata.extensions.taxDetails[0].taxScheme.id',
value: taxSchemeId,
source: 'PEPPOL'
});
}
// Check currency code is from ISO 4217
if (invoice.currency) {
// This is already validated by CodeListValidator, but we can add PEPPOL-specific check
if (!['EUR', 'USD', 'GBP', 'SEK', 'NOK', 'DKK', 'CHF', 'PLN', 'CZK', 'HUF'].includes(invoice.currency)) {
results.push({
ruleId: 'PEPPOL-S-02',
severity: 'info',
message: `Currency ${invoice.currency} is uncommon in PEPPOL network`,
field: 'currency',
value: invoice.currency,
source: 'PEPPOL'
});
}
}
return results;
}
/**
* Validate transport protocol requirements
*/
private validateTransportProtocol(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
// Check if transport protocol is specified
const transportProtocol = invoice.metadata?.extensions?.transportProtocol;
if (transportProtocol) {
const validProtocols = ['AS2', 'AS4'];
if (!validProtocols.includes(transportProtocol)) {
results.push({
ruleId: 'PEPPOL-P-01',
severity: 'warning',
message: 'Transport protocol should be AS2 or AS4 for PEPPOL',
field: 'metadata.extensions.transportProtocol',
value: transportProtocol,
source: 'PEPPOL'
});
}
}
// Check if SMP lookup is possible
const sellerEndpointId = invoice.metadata?.extensions?.sellerEndpointId;
if (sellerEndpointId && !invoice.metadata?.extensions?.smpRegistered) {
results.push({
ruleId: 'PEPPOL-P-02',
severity: 'info',
message: 'Seller endpoint should be registered in PEPPOL SMP for discovery',
field: 'metadata.extensions.smpRegistered',
source: 'PEPPOL'
});
}
return results;
}
/**
* Check if invoice is B2G (Business to Government)
*/
private isPeppolB2G(invoice: EInvoice): boolean {
// Check if buyer has government indicators
const buyerSchemeId = invoice.metadata?.extensions?.buyerPartyId?.schemeId;
const buyerCategory = invoice.metadata?.extensions?.buyerCategory;
// Government scheme IDs often include specific codes
const governmentSchemes = ['0204', '9905', '0197', '0215'];
// Check various indicators for government entity
return buyerCategory === 'government' ||
(buyerSchemeId && governmentSchemes.includes(buyerSchemeId)) ||
invoice.metadata?.extensions?.isB2G === true;
}
}

View File

@@ -0,0 +1,494 @@
/**
* XRechnung CIUS Validator
* Implements German-specific validation rules for XRechnung 3.0
*
* XRechnung is the German Core Invoice Usage Specification (CIUS) of EN16931
* Required for B2G invoicing in Germany since November 2020
*/
import type { EInvoice } from '../../einvoice.js';
import type { ValidationResult } from './validation.types.js';
/**
* XRechnung-specific validator implementing German CIUS rules
*/
export class XRechnungValidator {
private static readonly LEITWEG_ID_PATTERN = /^[0-9]{2,3}-[0-9]{1,12}-[0-9]{2,30}$/;
private static readonly IBAN_PATTERNS: Record<string, { length: number; pattern: RegExp }> = {
DE: { length: 22, pattern: /^DE[0-9]{2}[0-9]{8}[0-9]{10}$/ },
AT: { length: 20, pattern: /^AT[0-9]{2}[0-9]{5}[0-9]{11}$/ },
CH: { length: 21, pattern: /^CH[0-9]{2}[0-9]{5}[0-9A-Z]{12}$/ },
FR: { length: 27, pattern: /^FR[0-9]{2}[0-9]{5}[0-9]{5}[0-9A-Z]{11}[0-9]{2}$/ },
NL: { length: 18, pattern: /^NL[0-9]{2}[A-Z]{4}[0-9]{10}$/ },
BE: { length: 16, pattern: /^BE[0-9]{2}[0-9]{3}[0-9]{7}[0-9]{2}$/ },
IT: { length: 27, pattern: /^IT[0-9]{2}[A-Z][0-9]{5}[0-9]{5}[0-9A-Z]{12}$/ },
ES: { length: 24, pattern: /^ES[0-9]{2}[0-9]{4}[0-9]{4}[0-9]{2}[0-9]{10}$/ }
};
private static readonly BIC_PATTERN = /^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/;
// SEPA countries
private static readonly SEPA_COUNTRIES = new Set([
'AD', 'AT', 'BE', 'BG', 'CH', 'CY', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI',
'FR', 'GB', 'GI', 'GR', 'HR', 'HU', 'IE', 'IS', 'IT', 'LI', 'LT', 'LU',
'LV', 'MC', 'MT', 'NL', 'NO', 'PL', 'PT', 'RO', 'SE', 'SI', 'SK', 'SM', 'VA'
]);
/**
* Validate XRechnung-specific requirements
*/
validateXRechnung(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
// Check if this is an XRechnung invoice
if (!this.isXRechnungInvoice(invoice)) {
return results; // Not XRechnung, skip validation
}
// Validate mandatory fields
results.push(...this.validateLeitwegId(invoice));
results.push(...this.validateBuyerReference(invoice));
results.push(...this.validatePaymentDetails(invoice));
results.push(...this.validateSellerContact(invoice));
results.push(...this.validateTaxRegistration(invoice));
return results;
}
/**
* Check if invoice is XRechnung based on profile/customization ID
*/
private isXRechnungInvoice(invoice: EInvoice): boolean {
const profileId = invoice.metadata?.profileId || '';
const customizationId = invoice.metadata?.customizationId || '';
// XRechnung profile identifiers
const xrechnungProfiles = [
'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0',
'urn:cen.eu:en16931:2017#conformant#urn:xeinkauf.de:kosit:xrechnung_3.0',
'urn:cen.eu:en16931:2017:xrechnung',
'xrechnung'
];
return xrechnungProfiles.some(profile =>
profileId.toLowerCase().includes(profile.toLowerCase()) ||
customizationId.toLowerCase().includes(profile.toLowerCase())
);
}
/**
* Validate Leitweg-ID (routing ID for German public administration)
* Pattern: [0-9]{2,3}-[0-9]{1,12}-[0-9]{2,30}
* Rule: XR-DE-01
*/
private validateLeitwegId(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
// Leitweg-ID is typically in buyer reference (BT-10) for B2G
const buyerReference = invoice.metadata?.buyerReference || invoice.buyerReference || '';
// Check if it looks like a Leitweg-ID
if (buyerReference && this.looksLikeLeitwegId(buyerReference)) {
if (!XRechnungValidator.LEITWEG_ID_PATTERN.test(buyerReference.trim())) {
results.push({
ruleId: 'XR-DE-01',
severity: 'error',
source: 'XRECHNUNG',
message: `Invalid Leitweg-ID format: ${buyerReference}. Expected pattern: [0-9]{2,3}-[0-9]{1,12}-[0-9]{2,30}`,
btReference: 'BT-10',
field: 'buyerReference',
value: buyerReference
});
}
}
// For B2G invoices, Leitweg-ID might be mandatory
if (this.isB2GInvoice(invoice) && !buyerReference) {
results.push({
ruleId: 'XR-DE-15',
severity: 'error',
source: 'XRECHNUNG',
message: 'Buyer reference (Leitweg-ID) is mandatory for B2G invoices in Germany',
btReference: 'BT-10',
field: 'buyerReference'
});
}
return results;
}
/**
* Check if string looks like a Leitweg-ID
*/
private looksLikeLeitwegId(value: string): boolean {
// Contains dashes and numbers in the right proportion
return value.includes('-') && /^\d+-\d+-\d+$/.test(value.trim());
}
/**
* Check if this is a B2G invoice
*/
private isB2GInvoice(invoice: EInvoice): boolean {
// Check if buyer is a public entity (simplified check)
const buyerName = invoice.to?.name?.toLowerCase() || '';
const buyerType = invoice.metadata?.extensions?.buyerType?.toLowerCase() || '';
const publicIndicators = [
'bundesamt', 'landesamt', 'stadtverwaltung', 'gemeinde',
'ministerium', 'behörde', 'öffentlich', 'public', 'government'
];
return publicIndicators.some(indicator =>
buyerName.includes(indicator) || buyerType.includes(indicator)
);
}
/**
* Validate mandatory buyer reference (BT-10)
* Rule: XR-DE-15
*/
private validateBuyerReference(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
const buyerReference = invoice.metadata?.buyerReference || invoice.buyerReference || '';
// Skip if B2G invoice - already handled in validateLeitwegId
if (this.isB2GInvoice(invoice)) {
return results;
}
if (!buyerReference || buyerReference.trim().length === 0) {
results.push({
ruleId: 'XR-DE-15',
severity: 'error',
source: 'XRECHNUNG',
message: 'Buyer reference (BT-10) is mandatory in XRechnung',
btReference: 'BT-10',
field: 'buyerReference'
});
}
return results;
}
/**
* Validate payment details (IBAN/BIC for SEPA)
* Rules: XR-DE-19, XR-DE-20
*/
private validatePaymentDetails(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
// Check payment means
const paymentMeans = invoice.metadata?.extensions?.paymentMeans as Array<{
type?: string;
iban?: string;
bic?: string;
accountName?: string;
}> | undefined;
if (!paymentMeans || paymentMeans.length === 0) {
return results; // No payment details to validate
}
for (const payment of paymentMeans) {
// Validate IBAN if present
if (payment.iban) {
const ibanResult = this.validateIBAN(payment.iban);
if (!ibanResult.valid) {
results.push({
ruleId: 'XR-DE-19',
severity: 'error',
source: 'XRECHNUNG',
message: `Invalid IBAN: ${ibanResult.message}`,
btReference: 'BT-84',
field: 'iban',
value: payment.iban
});
}
// Check if IBAN country is in SEPA zone
const countryCode = payment.iban.substring(0, 2);
if (!XRechnungValidator.SEPA_COUNTRIES.has(countryCode)) {
results.push({
ruleId: 'XR-DE-19',
severity: 'warning',
source: 'XRECHNUNG',
message: `IBAN country ${countryCode} is not in SEPA zone`,
btReference: 'BT-84',
field: 'iban',
value: payment.iban
});
}
}
// Validate BIC if present
if (payment.bic) {
const bicResult = this.validateBIC(payment.bic);
if (!bicResult.valid) {
results.push({
ruleId: 'XR-DE-20',
severity: 'error',
source: 'XRECHNUNG',
message: `Invalid BIC: ${bicResult.message}`,
btReference: 'BT-86',
field: 'bic',
value: payment.bic
});
}
}
// For German domestic payments, BIC is optional if IBAN starts with DE
if (payment.iban?.startsWith('DE') && !payment.bic) {
// This is fine, BIC is optional for domestic German payments
} else if (payment.iban && !payment.iban.startsWith('DE') && !payment.bic) {
results.push({
ruleId: 'XR-DE-20',
severity: 'warning',
source: 'XRECHNUNG',
message: 'BIC is recommended for international SEPA transfers',
btReference: 'BT-86',
field: 'bic'
});
}
}
return results;
}
/**
* Validate IBAN format and checksum
*/
private validateIBAN(iban: string): { valid: boolean; message?: string } {
// Remove spaces and convert to uppercase
const cleanIBAN = iban.replace(/\s/g, '').toUpperCase();
// Check basic format
if (!/^[A-Z]{2}[0-9]{2}[A-Z0-9]+$/.test(cleanIBAN)) {
return { valid: false, message: 'Invalid IBAN format' };
}
// Get country code
const countryCode = cleanIBAN.substring(0, 2);
// Check country-specific format
const countryFormat = XRechnungValidator.IBAN_PATTERNS[countryCode];
if (countryFormat) {
if (cleanIBAN.length !== countryFormat.length) {
return {
valid: false,
message: `Invalid IBAN length for ${countryCode}: expected ${countryFormat.length}, got ${cleanIBAN.length}`
};
}
if (!countryFormat.pattern.test(cleanIBAN)) {
return {
valid: false,
message: `Invalid IBAN format for ${countryCode}`
};
}
}
// Validate checksum using mod-97 algorithm
const rearranged = cleanIBAN.substring(4) + cleanIBAN.substring(0, 4);
const numeric = rearranged.replace(/[A-Z]/g, char => (char.charCodeAt(0) - 55).toString());
// Calculate mod 97 for large numbers
let remainder = 0;
for (let i = 0; i < numeric.length; i++) {
remainder = (remainder * 10 + parseInt(numeric[i])) % 97;
}
if (remainder !== 1) {
return { valid: false, message: 'Invalid IBAN checksum' };
}
return { valid: true };
}
/**
* Validate BIC format
*/
private validateBIC(bic: string): { valid: boolean; message?: string } {
const cleanBIC = bic.replace(/\s/g, '').toUpperCase();
if (!XRechnungValidator.BIC_PATTERN.test(cleanBIC)) {
return {
valid: false,
message: 'Invalid BIC format. Expected 8 or 11 alphanumeric characters'
};
}
// Additional validation could check if BIC exists in SWIFT directory
// but that requires external data
return { valid: true };
}
/**
* Validate seller contact details
* Rule: XR-DE-02
*/
private validateSellerContact(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
// Seller contact is mandatory in XRechnung
const sellerContact = invoice.metadata?.extensions?.sellerContact as {
name?: string;
email?: string;
phone?: string;
} | undefined;
if (!sellerContact || (!sellerContact.name && !sellerContact.email && !sellerContact.phone)) {
results.push({
ruleId: 'XR-DE-02',
severity: 'error',
source: 'XRECHNUNG',
message: 'Seller contact information (name, email, or phone) is mandatory in XRechnung',
bgReference: 'BG-6',
field: 'sellerContact'
});
}
// Validate email format if present
if (sellerContact?.email && !this.isValidEmail(sellerContact.email)) {
results.push({
ruleId: 'XR-DE-02',
severity: 'warning',
source: 'XRECHNUNG',
message: `Invalid email format: ${sellerContact.email}`,
btReference: 'BT-43',
field: 'email',
value: sellerContact.email
});
}
// Validate phone format if present (basic validation)
if (sellerContact?.phone && !this.isValidPhone(sellerContact.phone)) {
results.push({
ruleId: 'XR-DE-02',
severity: 'warning',
source: 'XRECHNUNG',
message: `Invalid phone format: ${sellerContact.phone}`,
btReference: 'BT-42',
field: 'phone',
value: sellerContact.phone
});
}
return results;
}
/**
* Validate email format
*/
private isValidEmail(email: string): boolean {
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailPattern.test(email);
}
/**
* Validate phone format (basic)
*/
private isValidPhone(phone: string): boolean {
// Remove common formatting characters
const cleanPhone = phone.replace(/[\s\-\(\)\.]/g, '');
// Check if it contains only numbers and optional + at start
return /^\+?[0-9]{6,15}$/.test(cleanPhone);
}
/**
* Validate tax registration details
* Rules: XR-DE-03, XR-DE-04
*/
private validateTaxRegistration(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
const sellerVatId = invoice.metadata?.sellerTaxId ||
(invoice.from?.type === 'company' ? (invoice.from as any).registrationDetails?.vatId : undefined) ||
invoice.metadata?.extensions?.sellerVatId;
const sellerTaxId = invoice.metadata?.extensions?.sellerTaxId;
// Either VAT ID or Tax ID must be present
if (!sellerVatId && !sellerTaxId) {
results.push({
ruleId: 'XR-DE-03',
severity: 'error',
source: 'XRECHNUNG',
message: 'Either seller VAT ID (BT-31) or Tax ID (BT-32) must be provided',
btReference: 'BT-31',
field: 'sellerTaxRegistration'
});
}
// Validate German VAT ID format if present
if (sellerVatId && sellerVatId.startsWith('DE')) {
if (!this.isValidGermanVatId(sellerVatId)) {
results.push({
ruleId: 'XR-DE-04',
severity: 'error',
source: 'XRECHNUNG',
message: `Invalid German VAT ID format: ${sellerVatId}`,
btReference: 'BT-31',
field: 'vatId',
value: sellerVatId
});
}
}
// Validate German Tax ID format if present
if (sellerTaxId && this.looksLikeGermanTaxId(sellerTaxId)) {
if (!this.isValidGermanTaxId(sellerTaxId)) {
results.push({
ruleId: 'XR-DE-04',
severity: 'warning',
source: 'XRECHNUNG',
message: `Invalid German Tax ID format: ${sellerTaxId}`,
btReference: 'BT-32',
field: 'taxId',
value: sellerTaxId
});
}
}
return results;
}
/**
* Validate German VAT ID format
*/
private isValidGermanVatId(vatId: string): boolean {
// German VAT ID: DE followed by 9 digits
const germanVatPattern = /^DE[0-9]{9}$/;
return germanVatPattern.test(vatId.replace(/\s/g, ''));
}
/**
* Check if value looks like a German Tax ID
*/
private looksLikeGermanTaxId(value: string): boolean {
const clean = value.replace(/[\s\/\-]/g, '');
return /^[0-9]{10,11}$/.test(clean);
}
/**
* Validate German Tax ID format
*/
private isValidGermanTaxId(taxId: string): boolean {
// German Tax ID: 11 digits with specific checksum algorithm
const clean = taxId.replace(/[\s\/\-]/g, '');
if (!/^[0-9]{11}$/.test(clean)) {
return false;
}
// Simplified validation - full algorithm would require checksum calculation
// At least check that not all digits are the same
const firstDigit = clean[0];
return !clean.split('').every(digit => digit === firstDigit);
}
/**
* Create XRechnung profile validator instance
*/
static create(): XRechnungValidator {
return new XRechnungValidator();
}
}

View File

@@ -23,6 +23,13 @@ export interface IEInvoiceMetadata {
paidAmount?: number; // BT-113
amountDue?: number; // BT-115
// Tax identifiers
sellerTaxId?: string; // BT-31
buyerTaxId?: string; // BT-48
buyerReference?: string; // BT-10
profileId?: string; // BT-23
paymentTerms?: string; // BT-20
// Delivery information (BG-13)
deliveryAddress?: {
streetName?: string;