/** * 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?.extensions?.businessProcessId, specificationIdentifier: invoice.metadata.profileId } : undefined, // References references: { buyerReference: invoice.metadata?.buyerReference, projectReference: invoice.metadata?.extensions?.projectReference, contractReference: invoice.metadata?.extensions?.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?.extensions?.invoicingPeriod ? { startDate: invoice.metadata.extensions.invoicingPeriod.startDate, endDate: invoice.metadata.extensions.invoicingPeriod.endDate, descriptionCode: invoice.metadata.extensions.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) as 'invoice'; invoice.currency = model.documentInformation.currencyCode as any; 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, extensions: { ...invoice.metadata?.extensions, businessProcessId: model.processControl.businessProcessType } }; } // Set references if (model.references) { invoice.metadata = { ...invoice.metadata, buyerReference: model.references.buyerReference, extensions: { ...invoice.metadata?.extensions, contractReference: model.references.contractReference, purchaseOrderReference: model.references.purchaseOrderReference, salesOrderReference: model.references.salesOrderReference, precedingInvoices: model.references.precedingInvoices, 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 = { '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 = { '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; const paymentAccount = invoice.metadata?.extensions?.paymentAccount; return { paymentMeansTypeCode: paymentMeans?.paymentMeansCode || '30', // Default to credit transfer paymentMeansText: paymentMeans?.paymentMeansText, remittanceInformation: paymentMeans?.remittanceInformation, paymentAccountIdentifier: paymentAccount?.iban, paymentAccountName: paymentAccount?.accountName, paymentServiceProviderIdentifier: paymentAccount?.bic || 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; } }