lbx-invoice
Version:
Provides functionality around generating invoices.
233 lines (211 loc) • 11.8 kB
text/typescript
import { create as createXml } from 'xmlbuilder2';
import { XMLBuilder, XMLBuilderCreateOptions } from 'xmlbuilder2/lib/interfaces';
import { InvoiceCalcUtilities } from './invoice-calc.utilities';
import { dateTo102 } from '../functions';
import { getCustomerName } from '../functions/get-customer-name.function';
import { BaseCompanyInfo, BaseInvoice, InvoiceItem, Vat } from '../models';
/**
* Represents a wrapper around XML nodes to implement easy to use and chainable document builder methods.
*/
export type XML = XMLBuilder;
/**
* Utility class for handling xml files.
*/
export abstract class XmlUtilities {
/**
* Creates an XML document without any child nodes with the given options.
* @param options - Builder options.
* @returns Document node.
*/
static create(options: XMLBuilderCreateOptions = {
version: '1.0',
encoding: 'utf8'
}): XML {
return createXml(options);
}
/**
* Creates an cross industry invoice compliant xml file.
* @param invoice - The invoice data to generate the xml from.
* @param currency - The currency code, eg. 'USD'.
* @param companyInfo - Info about the seller.
* @param tradeContact - The trade contact. Defaults to companyInfo.ceo.
* @param documentContextId - The identifier for the GuidelineSpecifiedDocumentContextParameter ID.
* @returns The finished xml.
* @throws When the provided invoice data cannot be transformed to an valid xml.
*/
static createCrossIndustryInvoiceXml<Invoice extends BaseInvoice>(
invoice: Invoice,
currency: string,
companyInfo: BaseCompanyInfo,
tradeContact: string | undefined = companyInfo.ceo,
documentContextId: string = 'urn:cen.eu:en16931:2017'
): XML {
if (!tradeContact) {
throw new Error('No trade contact has been provided. (This defaults to companyInfo.CEO)');
}
const root: XML = XmlUtilities.create({
version: '1.0',
encoding: 'utf8'
}).ele(
'rsm:CrossIndustryInvoice', {
'xmlns:rsm': 'urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100',
'xmlns:qdt': 'urn:un:unece:uncefact:data:standard:QualifiedDataType:100',
'xmlns:ram': 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100',
'xmlns:xs': 'http://www.w3.org/2001/XMLSchema',
'xmlns:udt': 'urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100'
}
);
const documentContext: XML = root.ele('rsm:ExchangedDocumentContext');
documentContext.ele('ram:BusinessProcessSpecifiedDocumentContextParameter')
.ele('ram:ID')
.txt('urn:fdc:peppol.eu:2017:poacc:billing:01:1.0');
documentContext.ele('ram:GuidelineSpecifiedDocumentContextParameter')
.ele('ram:ID')
.txt(documentContextId);
const exchangedDocument: XML = root.ele('rsm:ExchangedDocument');
exchangedDocument.ele('ram:ID').txt(invoice.number);
// eslint-disable-next-line sonar/no-duplicate-string
exchangedDocument.ele('ram:TypeCode').txt('380'); // 380 = Commercial invoice
exchangedDocument.ele('ram:IssueDateTime')
// eslint-disable-next-line sonar/no-duplicate-string
.ele('udt:DateTimeString', { format: '102' })
.txt(dateTo102(invoice.date));
const tradeTransaction: XML = root.ele('rsm:SupplyChainTradeTransaction');
this.buildTradeLineItems(invoice, tradeTransaction);
const tradeAgreement: XML = tradeTransaction.ele('ram:ApplicableHeaderTradeAgreement');
tradeAgreement.ele('ram:BuyerReference').txt('999');
this.buildSellerParty(tradeAgreement, companyInfo, tradeContact);
this.buildBuyerParty<Invoice>(tradeAgreement, invoice);
tradeTransaction.ele('ram:ApplicableHeaderTradeDelivery')
.ele('ram:ActualDeliverySupplyChainEvent')
.ele('ram:OccurrenceDateTime')
.ele('udt:DateTimeString', { format: '102' })
.txt(dateTo102(invoice.performanceDate));
const tradeSettlement: XML = tradeTransaction.ele('ram:ApplicableHeaderTradeSettlement');
tradeSettlement.ele('ram:InvoiceCurrencyCode').txt(currency);
// payment options
const paymentMeans: XML = tradeSettlement.ele('ram:SpecifiedTradeSettlementPaymentMeans');
paymentMeans.ele('ram:TypeCode').txt('58');
if (!companyInfo.iban || !companyInfo.bic) {
throw new Error('At least the iban and bic need to be provided for payment information.');
}
paymentMeans.ele('ram:PayeePartyCreditorFinancialAccount').ele('ram:IBANID')
.txt(companyInfo.iban);
paymentMeans.ele('ram:PayeeSpecifiedCreditorFinancialInstitution').ele('ram:BICID')
.txt(companyInfo.bic);
// taxes
const taxGroups: Vat[] = this.getTaxGroups<Invoice>(invoice);
for (const taxGroup of taxGroups) {
const tax: XML = tradeSettlement.ele('ram:ApplicableTradeTax');
tax.ele('ram:CalculatedAmount').txt(InvoiceCalcUtilities.getTotalTaxForTaxGroup(invoice, taxGroup, false).toString());
tax.ele('ram:TypeCode').txt('VAT');
tax.ele('ram:BasisAmount').txt(InvoiceCalcUtilities.getTotalBeforeTaxForTaxGroup(invoice, taxGroup, false).toString());
tax.ele('ram:CategoryCode').txt(taxGroup.categoryCode as string);
tax.ele('ram:RateApplicablePercent').txt(taxGroup.rate.toString());
if (taxGroup.categoryCode === 'E') {
if (!taxGroup.exemptionReason) {
throw new Error('No exemption reason has been provided.');
}
tax.ele('ram:ExemptionReason').txt(taxGroup.exemptionReason);
}
}
// payment terms
tradeSettlement.ele('ram:SpecifiedTradePaymentTerms')
.ele('ram:DueDateDateTime')
.ele('udt:DateTimeString', { format: '102' })
.txt(dateTo102(invoice.dueDate));
// summary
const summary: XML = tradeSettlement.ele('ram:SpecifiedTradeSettlementHeaderMonetarySummation');
summary.ele('ram:LineTotalAmount').txt(InvoiceCalcUtilities.getTotalBeforeTax(invoice).toString());
// ChargeTotalAmount
// AllowanceTotalAmount
summary.ele('ram:TaxBasisTotalAmount').txt(InvoiceCalcUtilities.getTotalBeforeTax(invoice).toString());
summary.ele('ram:TaxTotalAmount').txt(InvoiceCalcUtilities.getTotalTax(invoice).toString())
.att('currencyID', currency);
summary.ele('ram:GrandTotalAmount').txt(InvoiceCalcUtilities.getTotalAfterTax(invoice).toString());
// TotalPrepaidAmount
summary.ele('ram:DuePayableAmount').txt(InvoiceCalcUtilities.getTotalAfterTax(invoice).toString());
return root;
}
private static buildBuyerParty<Invoice extends BaseInvoice>(tradeAgreement: XML, invoice: Invoice): void {
const buyerParty: XML = tradeAgreement.ele('ram:BuyerTradeParty');
buyerParty.ele('ram:Name').txt(getCustomerName(invoice));
if (!invoice.customerAddressData.email) {
throw new Error('No customer email has been provided (invoice.customerAddressData.email).');
}
buyerParty.ele('ram:URIUniversalCommunication')
.ele('ram:URIID', { schemeID: 'EM' })
.txt(invoice.customerAddressData.email);
const buyerPostalTradeAddress: XML = buyerParty.ele('ram:PostalTradeAddress');
buyerPostalTradeAddress.ele('ram:PostcodeCode').txt(invoice.customerAddressData.postcode);
buyerPostalTradeAddress.ele('ram:LineOne').txt(`${invoice.customerAddressData.street} ${invoice.customerAddressData.number}`);
buyerPostalTradeAddress.ele('ram:CityName').txt(invoice.customerAddressData.city);
buyerPostalTradeAddress.ele('ram:CountryID').txt(invoice.customerAddressData.countryId);
}
private static buildSellerParty(tradeAgreement: XML, companyInfo: BaseCompanyInfo, tradeContact: string): void {
const sellerParty: XML = tradeAgreement.ele('ram:SellerTradeParty');
sellerParty.ele('ram:Name').txt(companyInfo.fullName);
if (!companyInfo.taxNumber) {
throw new Error('No tax number has been provided.');
}
sellerParty.ele('ram:SpecifiedLegalOrganization')
.ele('ram:ID')
.txt(companyInfo.taxNumber);
sellerParty.ele('ram:URIUniversalCommunication')
.ele('ram:URIID', { schemeID: 'EM' })
.txt(companyInfo.email);
const sellerContact: XML = sellerParty.ele('ram:DefinedTradeContact');
sellerContact.ele('ram:PersonName').txt(tradeContact);
sellerContact.ele('ram:TelephoneUniversalCommunication')
.ele('ram:CompleteNumber')
.txt(companyInfo.phone);
sellerContact.ele('ram:EmailURIUniversalCommunication')
.ele('ram:URIID')
.txt(companyInfo.email);
const sellerPostalTradeAddress: XML = sellerParty.ele('ram:PostalTradeAddress');
sellerPostalTradeAddress.ele('ram:PostcodeCode').txt(companyInfo.address.postcode);
sellerPostalTradeAddress.ele('ram:LineOne').txt(`${companyInfo.address.street} ${companyInfo.address.number}`);
sellerPostalTradeAddress.ele('ram:CityName').txt(companyInfo.address.city);
sellerPostalTradeAddress.ele('ram:CountryID').txt(companyInfo.address.countryId);
if (companyInfo.taxNumber) {
sellerParty.ele('ram:SpecifiedTaxRegistration')
.ele('ram:ID', { schemeID: 'FC' })
.txt(companyInfo.taxNumber);
}
if (companyInfo.vatNumber) {
sellerParty.ele('ram:SpecifiedTaxRegistration')
.ele('ram:ID', { schemeID: 'VA' })
.txt(companyInfo.vatNumber);
}
}
private static buildTradeLineItems<Invoice extends BaseInvoice>(invoice: Invoice, tradeTransaction: XML): void {
for (let i: number = 0; i < invoice.items.length; i++) {
const invoiceItem: InvoiceItem = invoice.items[i];
const lineItem: XML = tradeTransaction.ele('ram:IncludedSupplyChainTradeLineItem');
lineItem.ele('ram:AssociatedDocumentLineDocument')
.ele('ram:LineID')
.txt((i + 1).toString());
lineItem.ele('ram:SpecifiedTradeProduct')
.ele('ram:Name')
.txt(invoiceItem.name);
lineItem.ele('ram:SpecifiedLineTradeAgreement')
.ele('ram:NetPriceProductTradePrice')
.ele('ram:ChargeAmount')
.txt(invoiceItem.price.toString());
lineItem.ele('ram:SpecifiedLineTradeDelivery')
.ele('ram:BilledQuantity', { unitCode: invoiceItem.amountUnit.code })
.txt(invoiceItem.amount.toString());
const tradeSettlement: XML = lineItem.ele('ram:SpecifiedLineTradeSettlement');
const tradeTax: XML = tradeSettlement.ele('ram:ApplicableTradeTax');
tradeTax.ele('ram:TypeCode').txt('VAT');
tradeTax.ele('ram:CategoryCode').txt(invoiceItem.vat.categoryCode as string);
tradeTax.ele('ram:RateApplicablePercent').txt(invoiceItem.vat.rate.toString());
tradeSettlement.ele('ram:SpecifiedTradeSettlementLineMonetarySummation')
.ele('ram:LineTotalAmount')
.txt(InvoiceCalcUtilities.getItemTotalPriceBeforeTax(invoiceItem).toString());
}
}
private static getTaxGroups<Invoice extends BaseInvoice>(invoice: Invoice): Vat[] {
return [...new Set(invoice.items.map(item => item.vat))];
}
}