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:
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
524
ts/formats/semantic/bt-bg.model.ts
Normal file
524
ts/formats/semantic/bt-bg.model.ts
Normal 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'];
|
596
ts/formats/semantic/semantic.adapter.ts
Normal file
596
ts/formats/semantic/semantic.adapter.ts
Normal 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;
|
||||
}
|
||||
}
|
654
ts/formats/semantic/semantic.validator.ts
Normal file
654
ts/formats/semantic/semantic.validator.ts
Normal 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;
|
||||
}
|
||||
}
|
323
ts/formats/utils/currency.calculator.decimal.ts
Normal file
323
ts/formats/utils/currency.calculator.decimal.ts
Normal 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
509
ts/formats/utils/decimal.ts
Normal 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];
|
@@ -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) =>
|
||||
|
579
ts/formats/validation/facturx.validator.ts
Normal file
579
ts/formats/validation/facturx.validator.ts
Normal 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];
|
||||
}
|
||||
}
|
405
ts/formats/validation/integrated.validator.ts
Normal file
405
ts/formats/validation/integrated.validator.ts
Normal 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';
|
589
ts/formats/validation/peppol.validator.ts
Normal file
589
ts/formats/validation/peppol.validator.ts
Normal 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;
|
||||
}
|
||||
}
|
494
ts/formats/validation/xrechnung.validator.ts
Normal file
494
ts/formats/validation/xrechnung.validator.ts
Normal 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();
|
||||
}
|
||||
}
|
@@ -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;
|
||||
|
Reference in New Issue
Block a user