lbx-invoice
Version:
Provides functionality around generating invoices.
726 lines (659 loc) • 24.6 kB
text/typescript
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;
}
}