UNPKG

lbx-invoice

Version:

Provides functionality around generating invoices.

726 lines (659 loc) 24.6 kB
import { execSync } from 'child_process'; import { PathLike } from 'fs'; import path from 'path'; import { getCustomerName } from '../functions'; import { BaseInvoice, BaseCompanyInfo, Vat } from '../models'; import { XRechnungUtilities } from '../utilities'; import { BigNumber, BigNumberUtilities } from '../utilities/big-number.utilities'; import { InvoiceCalcUtilities } from '../utilities/invoice-calc.utilities'; import { PdfMakeColumn, PdfMakeContent, PdfMakeDocumentDefinition, PdfMakeDynamicContent, PdfMakeImageDefinition, PdfMakeSize, PdfMakeTableCell, PdfUtilities } from '../utilities/pdf.utilities'; // TODO: getTaxGroupsFor // TODO: xml number format (only 2 numbers after the comma) /** * Handles the generation of invoice pdfs. * Uses pdfmake. */ export abstract class BasePdfService<Invoice extends BaseInvoice, CompanyInfo extends BaseCompanyInfo> { /** * The font size that should be used for the footer. * @default 10 */ protected readonly FOOTER_FONT_SIZE: number = 10; /** * The currency used for formatting prices. * @default 'USD' */ protected readonly CURRENCY: string = 'USD'; /** * The locale to use for formatting dates. * @default 'en-US' */ protected readonly LOCALE: string = 'en-US'; /** * The timezone to use for formatting dates. * @default 'America/New_York' */ protected readonly TIMEZONE: string = 'America/New_York'; /** * Globally defined images which can be referenced by name inside the document definition. * @default undefined */ protected readonly IMAGES?: Record<string, string | PdfMakeImageDefinition> = undefined; /** * Available options: * * - A reference by name to an image defined in PdfMakeDocumentDefinition.images * - A data URL * - A remote URL via http:// or https://. * * Supported image formats: JPEG, PNG. * @default undefined */ protected readonly LOGO?: string = undefined; /** * The label for the phone number on the invoice. * @default 'Phone' */ protected readonly PHONE_LABEL: string = 'Phone'; /** * The label for the email on the invoice. * @default 'E-Mail' */ protected readonly EMAIL_LABEL: string = 'E-Mail'; /** * The headline of the invoice. * @default 'Invoice' */ protected readonly HEADLINE: string = 'Invoice'; /** * The font size of the headline. * @default 20 */ protected readonly HEADLINE_FONT_SIZE: number = 20; /** * The label for the invoice number on the invoice. * @default 'Invoice No.' */ protected readonly INVOICE_NUMBER_LABEL: string = 'Invoice No.'; /** * The notice below the invoice number. * @default 'Please specify when making payments and invoicing!' */ protected readonly INVOICE_NUMBER_NOTICE: string = 'Please specify when making payments and invoicing!'; /** * The label for the date on the invoice. * @default 'Date' */ protected readonly DATE_LABEL: string = 'Date'; /** * The label for the date of performance on the invoice. * @default 'Date of performance' */ protected readonly PERFORMANCE_DATE_LABEL: string = 'Date of performance'; /** * Whether or not a performance date should be displayed on the invoice. * @default false */ protected readonly SHOW_PERFORMANCE_DATE: boolean = false; /** * Whether or not a due date should be displayed on the invoice. * @default true */ protected readonly SHOW_DUE_DATE: boolean = true; /** * The label for the due date on the invoice. * @default 'Due date' */ protected readonly DUE_DATE_LABEL: string = 'Due date'; /** * The font size that should be used inside the tables. * @default 12 */ protected readonly TABLE_FONT_SIZE: number = 12; /** * The label for item name on the invoice. * @default 'Name' */ protected readonly ITEM_NAME_LABEL: string = 'Name'; /** * The label for the total when no taxes exist on the invoice. * @default 'Total' */ protected readonly TOTAL_LABEL: string = 'Total'; /** * The label for the total before tax value on the invoice. * @default 'Total (excl. tax)' */ protected readonly TOTAL_BEFORE_TAX_LABEL: string = 'Total (excl. tax)'; /** * The label for the total after tax value on the invoice. * @default 'Total (incl. tax)' */ protected readonly TOTAL_AFTER_TAX_LABEL: string = 'Total (incl. tax)'; /** * The label for tax value on the invoice. * @default 'VAT' */ protected readonly TAX_LABEL: string = 'VAT'; /** * The label for tax number on the invoice. * @default 'Tax number:' */ protected readonly TAX_NUMBER_LABEL: string = 'Tax number:'; /** * The label for the ceo on the invoice. * @default 'CEO:' */ protected readonly CEO_LABEL: string = 'CEO:'; /** * The label for item amount on the invoice. * @default 'Amount' */ protected readonly ITEM_AMOUNT_LABEL: string = 'Amount'; /** * The label for single price of an item on the invoice. * @default 'Price' */ protected readonly ITEM_SINGLE_PRICE_LABEL: string = 'Price'; /** * The label for total price of an item on the invoice. * @default 'Total' */ protected readonly ITEM_TOTAL_PRICE_LABEL: string = 'Total'; /** * The tax office to display in the footer. * Can be omitted. */ protected abstract readonly TAX_OFFICE?: string; /** * The label for the bank details in the footer. */ protected readonly BANK_DETAILS_LABEL: string = 'Bank details:'; /** * The label for the iban to display in the footer. * @default 'IBAN:' */ protected readonly IBAN_LABEL: string = 'IBAN:'; /** * The label for the bic to display in the footer. * @default 'BIC/SWIFT:' */ protected readonly BIC_LABEL: string = 'BIC/SWIFT:'; /** * The bank name to display in the footer. * Can be omitted. */ protected abstract readonly BANK_NAME?: string; /** * Information about the company like the address. */ protected abstract readonly companyInfo: CompanyInfo; /** * Creates a pdf for the provided invoice and handles it afterwards. * @param invoice - The invoice to create the pdf for. */ async createInvoicePdf(invoice: Invoice): Promise<void> { const documentDefinition: PdfMakeDocumentDefinition = this.generateInvoiceDocumentDefinition(invoice); const pdfData: string = await PdfUtilities.createPdf(documentDefinition); await this.handleFinishedInvoicePdf(pdfData, invoice); } /** * Converts the pdf at the given path to pdf-a. * @param pdfFilePath - The path to the pdf that should be converted. * @throws When there was an error in the script. */ pdfToPdfA(pdfFilePath: string | PathLike): void { const scriptPath: string = path.join(__dirname, './convert-to-pdf-a.sh'); const res: string = execSync(`${scriptPath} ${pdfFilePath} ${pdfFilePath}`, { encoding: 'utf8' }); if (res.includes('PDF-A ERROR')) { throw new Error(res); } } /** * Attaches an X-Rechnung to the invoice pdf at the given path. * @param pdfFilePath - The path of the invoice pdf that the X-Rechnung should be attached to. * @param invoice - The invoice data. */ async pdfToXRechnung(pdfFilePath: string, invoice: Invoice): Promise<void> { this.pdfToPdfA(pdfFilePath); await XRechnungUtilities.attachXRechnung(pdfFilePath, invoice, this.CURRENCY, this.companyInfo); } /** * Converts the invoice pdf at the given path to conform to the factur-x invoice standard. * @param pdfFilePath - The path of the invoice pdf that should be transformed. * @param invoice - The invoice data. * @param producer - The producer that should be set. Defaults to `lbx-invoice`. */ // async pdfToFacturX(pdfFilePath: string, invoice: Invoice, producer: string = 'lbx-invoice'): Promise<void> { // // Adds Factur X metadata // await FacturXUtilities.addFacturXMetadata(pdfFilePath, this.companyInfo.name, producer); // // TODO: Remove // const metadata: string | undefined = await PdfUtilities.getXmpMetadata(pdfFilePath); // // await writeFile(pdfFilePath.replace('.pdf', '.xml'), metadata.sizeInBytes()); // console.log('\nMetadata after adding factur-x metadata', metadata); // // first to pdf-a because that removes the factur-x.xml (we need to reattach the xml afterwards). // this.pdfToPdfA(pdfFilePath); // // attach // await FacturXUtilities.attachFacturX(pdfFilePath, invoice, this.CURRENCY, this.companyInfo); // } /** * What to do with the pdf after it has been created. * @param pdfData - The pdf content in the form of a base 64 string. */ protected abstract handleFinishedInvoicePdf(pdfData: string, invoice: Invoice): unknown; /** * Generates a pdfmake document definition for the given invoice. * @param invoice - The invoice to generate the document definition for. * @returns The invoice document definition. */ protected generateInvoiceDocumentDefinition(invoice: Invoice): PdfMakeDocumentDefinition { const taxGroups: Vat[] = this.getTaxGroups(invoice); const res: PdfMakeDocumentDefinition = { pageSize: 'A4', pageMargins: [40, 40, 40, 70], images: this.IMAGES, header: this.getHeader(), content: [ this.getLetterhead(invoice), this.getHeadline(), this.getNumberAndDates(invoice), ...this.getContentFromParagraphs(invoice.textBeforeItems), this.getInvoiceItemsTable(invoice, taxGroups), this.getTotalTable(invoice, taxGroups), ...this.getContentFromParagraphs(invoice.textAfterItems) ], footer: this.getFooter() }; return res; } /** * Gets all relevant tax groups for the pdf file. * @param invoice - The invoice to get the tax groups for. * @returns The tax groups for the pdf. */ protected getTaxGroups(invoice: Invoice): Vat[] { const vats: Vat[] = [...new Set(invoice.items.map(item => item.vat))]; return vats.filter(v => v.rate !== 0); } /** * Gets the table that displays the total price of all items. * @param invoice - The invoice to get the table from. * @param taxGroups - The different tax groups that need to be taken into consideration. * @returns The total price of all items and the tax groups. */ protected getTotalTable(invoice: Invoice, taxGroups: Vat[]): PdfMakeContent { return { table: { headerRows: 0, widths: ['*', 159], body: this.getTotalBody(invoice, taxGroups) }, fontSize: this.TABLE_FONT_SIZE, bold: true, margin: [0, 15, 0, 20] }; } /** * Gets the body of the total table. * @param invoice - The invoice to build the body from. * @param taxGroups - The different tax groups that need to be taken into consideration. * @returns The body of the total table. */ protected getTotalBody(invoice: Invoice, taxGroups: Vat[]): PdfMakeTableCell[][] { if (!taxGroups.length) { return [[this.TOTAL_LABEL, this.formatPrice(InvoiceCalcUtilities.getTotalBeforeTax(invoice).toNumber())]]; } const res: PdfMakeTableCell[][] = [ [ this.TOTAL_BEFORE_TAX_LABEL, this.formatPrice(InvoiceCalcUtilities.getTotalBeforeTax(invoice).toNumber()) ] ]; for (const group of taxGroups) { res.push([ `${this.formatPercent(group.rate)} ${this.TAX_LABEL}`, this.formatPrice(InvoiceCalcUtilities.getTotalTaxForTaxGroup(invoice, group, true).toNumber()) ]); } res.push([this.TOTAL_AFTER_TAX_LABEL, this.formatPrice(InvoiceCalcUtilities.getTotalAfterTax(invoice).toNumber())]); return res; } /** * Gets the table that displays all invoice items. * @param invoice - The invoice to build the table from. * @param taxGroups - The different tax groups that need to be taken into consideration. * @returns The table with all invoice items. */ protected getInvoiceItemsTable(invoice: Invoice, taxGroups: Vat[]): PdfMakeContent { const widths: PdfMakeSize[] = taxGroups.length ? ['*', 75, 75, 75, 75] : ['*', 100, 100, 100]; return { table: { headerRows: 1, widths: widths, body: this.getInvoiceItemsBody(invoice, taxGroups) }, fontSize: this.TABLE_FONT_SIZE, margin: [0, 25, 0, 0] }; } /** * Gets the body of the invoice items table. * @param invoice - The invoice to build the body from. * @param taxGroups - The different tax groups that need to be taken into consideration. * @returns The body of the invoice items table. */ protected getInvoiceItemsBody(invoice: Invoice, taxGroups: Vat[]): PdfMakeTableCell[][] { const res: PdfMakeTableCell[][] = [this.getInvoiceItemsHeaders(taxGroups)]; for (const item of invoice.items) { const totalPricePreTax: BigNumber = InvoiceCalcUtilities.getItemTotalPriceBeforeTax(item); const amount: string = `${item.amount} ${item.amountUnit.displayName}`; if (taxGroups.length) { res.push([ item.name, amount, this.formatPrice(item.price), item.vat.rate !== 0 ? this.formatPercent(item.vat.rate) : '-', this.formatPrice(totalPricePreTax.toNumber()) ]); } else { res.push([ item.name, amount, this.formatPrice(item.price), this.formatPrice(totalPricePreTax.toNumber()) ]); } } return res; } /** * Gets the headers for the invoice items table. If the only tax group is 0, the tax column is removed. * @param taxGroups - The different tax groups that need to be taken into consideration. * @returns The headers of the invoice items table. */ protected getInvoiceItemsHeaders(taxGroups: Vat[]): PdfMakeTableCell[] { const headers: PdfMakeTableCell[] = [ { text: this.ITEM_NAME_LABEL, style: { bold: true } }, { text: this.ITEM_AMOUNT_LABEL, style: { bold: true } }, { text: this.ITEM_SINGLE_PRICE_LABEL, style: { bold: true } } ]; if (taxGroups.length) { headers.push({ text: this.TAX_LABEL, style: { bold: true } }); } headers.push({ text: this.ITEM_TOTAL_PRICE_LABEL, style: { bold: true } }); return headers; } /** * Formats the given number as a price. * Uses the locale and currency values of this class. * @param value - The number to format as a price. * @returns A string representation of the number, including the currency symbol etc. */ protected formatPrice(value: number): string { return value.toLocaleString(this.LOCALE, { style: 'currency', currency: this.CURRENCY, minimumFractionDigits: 2, maximumFractionDigits: 2 }); } /** * Formats the given number as a percentage. * Uses the locale of this class. * @param value - The number to format as a percentage. * @returns A string representation of the number, including the percentage symbol. */ protected formatPercent(value: number | BigNumber): string { return BigNumberUtilities.divide(value, 100).toNumber() .toLocaleString(this.LOCALE, { style: 'percent' }); } /** * Gets the text that should be displayed before the actual invoice items start. * @param paragraphs - The paragraphs to get the text from. * @returns The text that should be displayed before the actual invoice items. */ protected getContentFromParagraphs(paragraphs: string[]): PdfMakeContent[] { return paragraphs.map(t => { return { text: t, margin: [0, 10, 0, 0] }; }); } /** * Gets the headline of the invoice. * @returns The headline. */ protected getHeadline(): PdfMakeContent { return { text: this.HEADLINE, fontSize: this.HEADLINE_FONT_SIZE, style: { bold: true }, margin: [0, 10, 0, 10] }; } /** * Gets the invoice number and the dates. * @param invoice - The invoice to build the content from. * @returns The content containing the invoice number and the date, performance date and due date. */ protected getNumberAndDates(invoice: Invoice): PdfMakeContent { const invoiceNumberColumn: PdfMakeColumn = [ { text: `${this.INVOICE_NUMBER_LABEL}: ${invoice.number}`, alignment: 'left' }, { text: this.INVOICE_NUMBER_NOTICE, fontSize: 8, italics: true } ]; const dateColumn: PdfMakeColumn = [ { text: `${this.DATE_LABEL}: ${this.formatDate(invoice.date)}`, alignment: 'right' } ]; if (this.SHOW_PERFORMANCE_DATE) { dateColumn.push({ text: `${this.PERFORMANCE_DATE_LABEL}: ${this.formatDate(invoice.performanceDate)}`, alignment: 'right' }); } if (this.SHOW_DUE_DATE) { dateColumn.push({ text: `${this.DUE_DATE_LABEL}: ${this.formatDate(invoice.dueDate)}`, alignment: 'right' }); } return { columns: [ invoiceNumberColumn, dateColumn ] }; } /** * Formats the given date using the locale and timezone. * @param date - The date to format. * @returns The formatted date string with the locale and timezone. */ protected formatDate(date: Date): string { return new Date(date).toLocaleString(this.LOCALE, { timeZone: this.TIMEZONE, day: '2-digit', month: '2-digit', year: 'numeric' }); } /** * Gets the Content of the header. * @returns A row with the logo or an empty array if no logo has been specified. */ protected getHeader(): PdfMakeDynamicContent | PdfMakeContent { if (!this.LOGO) { return []; } return [ { columns: [ { width: '*', text: '' }, { fit: [500, 25], image: this.LOGO } ] } ]; } /** * Gets the letterhead. * @param invoice - The invoice to get the letterhead from. * @returns The letterhead of the customer and the company. */ protected getLetterhead(invoice: Invoice): PdfMakeContent { return { columns: [ this.getCustomerLetterheadColumn(invoice), { columns: [this.getCompanyLetterheadColumn()], width: 'auto' } ] }; } /** * Gets the letterhead of the company. * @returns The letterhead of the company, containing full name, address and contact information. */ protected getCompanyLetterheadColumn(): PdfMakeColumn { return [ { text: this.companyInfo.fullName, style: { bold: true } }, `${this.companyInfo.address.street} ${this.companyInfo.address.number}`, { text: `${this.companyInfo.address.postcode} ${this.companyInfo.address.city}`, margin: [0, 0, 0, 10] }, `${this.PHONE_LABEL}: ${this.companyInfo.phone}`, `${this.EMAIL_LABEL}: ${this.companyInfo.email}` ]; } /** * Gets the letterhead of the customer. * @param invoice - The invoice to get the customer letterhead from. * @returns The letterhead of the customer, containing the name and address. */ protected getCustomerLetterheadColumn(invoice: Invoice): PdfMakeColumn { // eslint-disable-next-line stylistic/max-len const companyAddressLine: string = `${this.companyInfo.fullName} - ${this.companyInfo.address.street} ${this.companyInfo.address.number} - ${this.companyInfo.address.postcode} ${this.companyInfo.address.city}`; const fontSize: number = this.getFontSizeForLetterhead(companyAddressLine); return [ { text: companyAddressLine, fontSize: fontSize, alignment: 'left', margin: [0, 0, 0, 5], decoration: 'underline' }, getCustomerName(invoice), `${invoice.customerAddressData.street} ${invoice.customerAddressData.number}`, `${invoice.customerAddressData.postcode} ${invoice.customerAddressData.city}` ]; } /** * Gets the font size for the provided company address line. * Checks if the address line is too big and makes the font smaller accordingly. * @param companyAddressLine - The company address line of the invoice. * @returns The font size to use for the letter head. */ protected getFontSizeForLetterhead(companyAddressLine: string): number { if (companyAddressLine.length > 85) { return 4; } if (companyAddressLine.length > 65) { return 6; } return 8; } /** * Gets the Content of the footer. * @returns Multiple rows containing information about the company and payment data. */ protected getFooter(): PdfMakeDynamicContent | PdfMakeContent { return { columns: [ this.getFirstFooterColumn(), this.getSecondFooterColumn() ], margin: [40, 0, 0, 0], fontSize: this.FOOTER_FONT_SIZE }; } /** * Gets the first column of the footer. * @returns The column definition. */ protected getFirstFooterColumn(): PdfMakeColumn { const res: PdfMakeColumn = [ { text: this.BANK_DETAILS_LABEL, style: { bold: true } } ]; if (this.BANK_NAME) { res.push(this.BANK_NAME); } if (this.companyInfo.iban) { res.push(`${this.IBAN_LABEL} ${this.companyInfo.iban}`); } if (this.companyInfo.bic) { res.push(`${this.BIC_LABEL} ${this.companyInfo.bic}`); } return res; } /** * Gets the second column of the footer. * @returns The column definition. */ protected getSecondFooterColumn(): PdfMakeColumn { const res: PdfMakeColumn = [ { text: this.companyInfo.fullName, style: { bold: true } } ]; if (this.companyInfo.taxNumber) { res.push(`${this.TAX_NUMBER_LABEL} ${this.companyInfo.taxNumber}`); } if (this.TAX_OFFICE) { res.push(this.TAX_OFFICE); } if (this.companyInfo.ceo) { res.push(`${this.CEO_LABEL} ${this.companyInfo.ceo}`); } return res; } }