rimiti/invoice-it

View on GitHub
src/classes/generator.js

Summary

Maintainability
F
3 days
Test Coverage
import moment from 'moment';
import pug from 'pug';
import fs from 'fs';
import path from 'path';
import htmlPDF from 'html-pdf';
import Common from './common';
import Recipient from './recipient';
import Emitter from './emitter';
import i18n from '../lib/i18n';

export default class Generator extends Common {
  constructor(config) {
    super();
    this._recipient = (config.recipient) ? new Recipient(config.recipient) : new Recipient();
    this._emitter = (config.emitter) ? new Emitter(config.emitter) : new Emitter();
    this._total_exc_taxes = 0;
    this._total_taxes = 0;
    this._total_inc_taxes = 0;
    this._article = [];
    this._i18nConfigure(config.language);
    this.hydrate(config.global, this._itemsToHydrate());
  }

  get template() {
    return this._template;
  }

  set template(value) {
    this._template = value;
  }

  get lang() {
    return (!this._lang) ? this._defaultLocale : this._lang;
  }

  set lang(value) {
    const tmp = value.toLowerCase();
    if (!this._availableLocale.includes(tmp)) throw new Error(`Wrong lang, please set one of ${this._availableLocale.join(', ')}`);
    this._lang = tmp;
  }

  get id() {
    return this._id;
  }

  set id(value) {
    this._id = value;
  }

  get order_reference_pattern() {
    return (!this._order_reference_pattern) ? '$prefix{OR}$date{YYMM}$separator{-}$id{00000}' : this._order_reference_pattern;
  }

  set order_reference_pattern(value) {
    this._order_reference_pattern = value;
  }

  get invoice_reference_pattern() {
    return (!this._invoice_reference_pattern) ? '$prefix{IN}$date{YYMM}$separator{-}$id{00000}' : this._invoice_reference_pattern;
  }

  set invoice_reference_pattern(value) {
    this._invoice_reference_pattern = value;
  }

  get reference() {
    return this._reference;
  }

  set reference(value) {
    this._reference = value;
  }

  get logo() {
    return this._logo;
  }

  set logo(value) {
    this._logo = value;
  }

  get order_template() {
    return this._order_template;
  }

  set order_template(value) {
    this._order_template = value;
  }

  get invoice_template() {
    return this._invoice_template;
  }

  set invoice_template(value) {
    this._invoice_template = value;
  }

  get order_note() {
    return this._order_note;
  }

  set order_note(value) {
    this._order_note = value;
  }

  get invoice_note() {
    return this._invoice_note;
  }

  set invoice_note(value) {
    this._invoice_note = value;
  }

  get footer() {
    return this._footer;
  }

  set footer(value) {
    this._footer = value;
  }

  get date_format() {
    return (!this._date_format) ? 'YYYY/MM/DD' : this._date_format;
  }

  set date_format(value) {
    this._date_format = value;
  }

  get date() {
    return (!this._date) ? moment().format(this.date_format) : this._date;
  }

  set date(value) {
    if (!moment(value).isValid()) throw new Error('Date not valid');
    this._date = moment(value).format(this.date_format);
  }

  get total_exc_taxes() {
    return this._total_exc_taxes;
  }

  set total_exc_taxes(value) {
    this._total_exc_taxes = value;
  }

  get total_taxes() {
    return this._total_taxes;
  }

  set total_taxes(value) {
    this._total_taxes = value;
  }

  get total_inc_taxes() {
    return this._total_inc_taxes;
  }

  set total_inc_taxes(value) {
    this._total_inc_taxes = value;
  }

  get article() {
    return this._article;
  }

  /**
   * @description Set
   * @param value
   * @example article({description: 'Licence', tax: 20, price: 100, qt: 1})
   * @example article([
   *  {description: 'Licence', tax: 20, price: 100, qt: 1},
   *  {description: 'Licence', tax: 20, price: 100, qt: 1}
   * ])
   */
  set article(value) {
    const tmp = value;
    if (Array.isArray(tmp)) {
      for (let i = 0; i < tmp.length; i += 1) {
        this._checkArticle(tmp[i]);
        tmp[i].total_product_without_taxes = this.formatOutputNumber(tmp[i].price * tmp[i].qt);
        tmp[i].total_product_taxes = this.formatOutputNumber(this.round(tmp[i].total_product_without_taxes * (tmp[i].tax / 100)));
        tmp[i].total_product_with_taxes = this.formatOutputNumber(this.round(Number(tmp[i].total_product_without_taxes) + Number(tmp[i].total_product_taxes)));
        tmp[i].price = this.formatOutputNumber(tmp[i].price);
        tmp[i].tax = this.formatOutputNumber(tmp[i].tax);
        this.total_exc_taxes += Number(tmp[i].total_product_without_taxes);
        this.total_inc_taxes += Number(tmp[i].total_product_with_taxes);
        this.total_taxes += Number(tmp[i].total_product_taxes);
      }
    } else {
      this._checkArticle(tmp);
      tmp.total_product_without_taxes = this.formatOutputNumber(tmp.price * tmp.qt);
      tmp.total_product_taxes = this.formatOutputNumber(this.round(tmp.total_product_without_taxes * (tmp.tax / 100)));
      tmp.total_product_with_taxes = this.formatOutputNumber(this.round(Number(tmp.total_product_without_taxes) + Number(tmp.total_product_taxes)));
      tmp.price = this.formatOutputNumber(tmp.price);
      tmp.tax = this.formatOutputNumber(tmp.tax);
      this.total_exc_taxes += Number(tmp.total_product_without_taxes);
      this.total_inc_taxes += Number(tmp.total_product_with_taxes);
      this.total_taxes += Number(tmp.total_product_taxes);
    }
    this._article = (this._article) ? this._article.concat(tmp) : [].concat(tmp);
  }

  /**
   * @description Reinitialize article attribute
   */
  deleteArticles() {
    this._total_inc_taxes = 0;
    this._total_taxes = 0;
    this._total_exc_taxes = 0;
    this._article = [];
  }

  /**
   * @description Check article structure and data
   * @param article
   * @private
   */
  _checkArticle(article) {
    if (!Object.prototype.hasOwnProperty.call(article, 'description')) throw new Error('Description attribute is missing');
    if (!Object.prototype.hasOwnProperty.call(article, 'tax')) throw new Error('Tax attribute is missing');
    if (!this.isNumeric(article.tax)) throw new Error('Tax attribute have to be a number');
    if (!Object.prototype.hasOwnProperty.call(article, 'price')) throw new Error('Price attribute is missing');
    if (!this.isNumeric(article.price)) throw new Error('Price attribute have to be a number');
    if (!Object.prototype.hasOwnProperty.call(article, 'qt')) throw new Error('Qt attribute is missing');
    if (!this.isNumeric(article.qt)) throw new Error('Qt attribute have to be an integer');
    if (!Number.isInteger(article.qt)) throw new Error('Qt attribute have to be an integer, not a float');
  }

  /**
   * @description Hydrate from configuration
   * @returns {[string,string,string,string]}
   */
  _itemsToHydrate() {
    return ['logo', 'order_template', 'invoice_template', 'date_format', 'date', 'order_reference_pattern', 'invoice_reference_pattern', 'order_note', 'invoice_note', 'lang', 'footer'];
  }

  /**
   * @description Hydrate recipient object
   * @param obj
   * @returns {*}
   */
  recipient(obj) {
    if (!obj) return this._recipient;
    return this._recipient.hydrate(obj, this._recipient._itemsToHydrate());
  }

  /**
   * @description Hydrate emitter object
   * @param obj
   * @returns {*}
   */
  emitter(obj) {
    if (!obj) return this._emitter;
    return this._emitter.hydrate(obj, this._emitter._itemsToHydrate());
  }

  /**
   * @description Precompile translation to merging glabal with custom translations
   * @returns {{logo: *, header_date: *, table_information, table_description, table_tax, table_quantity,
   * table_price_without_taxes, table_price_without_taxes_unit, table_note, table_total_without_taxes,
   * table_total_taxes, table_total_with_taxes, fromto_phone, fromto_mail, footer, moment: (*|moment.Moment)}}
   * @private
   */
  _preCompileCommonTranslations() {
    return {
      logo: this.logo,
      header_date: this.date,
      table_information: i18n.__({phrase: 'table_information', locale: this.lang}),
      table_description: i18n.__({phrase: 'table_description', locale: this.lang}),
      table_tax: i18n.__({phrase: 'table_tax', locale: this.lang}),
      table_quantity: i18n.__({phrase: 'table_quantity', locale: this.lang}),
      table_price_without_taxes: i18n.__({phrase: 'table_price_without_taxes', locale: this.lang}),
      table_price_without_taxes_unit: i18n.__({phrase: 'table_price_without_taxes_unit', locale: this.lang}),
      table_note: i18n.__({phrase: 'table_note', locale: this.lang}),
      table_total_without_taxes: i18n.__({phrase: 'table_total_without_taxes', locale: this.lang}),
      table_total_taxes: i18n.__({phrase: 'table_total_taxes', locale: this.lang}),
      table_total_with_taxes: i18n.__({phrase: 'table_total_with_taxes', locale: this.lang}),
      fromto_phone: i18n.__({phrase: 'fromto_phone', locale: this.lang}),
      fromto_mail: i18n.__({phrase: 'fromto_mail', locale: this.lang}),
      footer: this.getFooter(),
      emitter_name: this.emitter().name,
      emitter_street_number: this.emitter().street_number,
      emitter_street_name: this.emitter().street_name,
      emitter_zip_code: this.emitter().zip_code,
      emitter_city: this.emitter().city,
      emitter_country: this.emitter().country,
      emitter_phone: this.emitter().phone,
      emitter_mail: this.emitter().mail,
      recipient_company: this.recipient().company_name,
      recipient_first_name: this.recipient().first_name,
      recipient_last_name: this.recipient().last_name,
      recipient_street_number: this.recipient().street_number,
      recipient_street_name: this.recipient().street_name,
      recipient_zip_code: this.recipient().zip_code,
      recipient_city: this.recipient().city,
      recipient_country: this.recipient().country,
      recipient_phone: this.recipient().phone,
      recipient_mail: this.recipient().mail,
      articles: this.article,
      table_total_without_taxes_value: this.formatOutputNumber(this.total_exc_taxes),
      table_total_taxes_value: this.formatOutputNumber(this.total_taxes),
      table_total_with_taxes_value: this.formatOutputNumber(this.total_inc_taxes),
      template_configuration: this._templateConfiguration(),
      moment: moment(),
    };
  }

  /**
   * @description Compile pug template to HTML
   * @param keys
   * @returns {*}
   * @private
   */
  _compile(keys) {
    const template = keys.filename === 'order' ? this.order_template : this.invoice_template;
    const compiled = pug.compileFile(path.resolve(template));
    return compiled(keys);
  }

  /**
   * @description Prepare phrases from translations
   * @param type
   */
  getPhrases(type) {
    return {
      header_title: i18n.__({phrase: `${type}_header_title`, locale: this.lang}),
      header_subject: i18n.__({phrase: `${type}_header_subject`, locale: this.lang}),
      header_reference: i18n.__({phrase: `${type}_header_reference`, locale: this.lang}),
      header_date: i18n.__({phrase: `${type}_header_date`, locale: this.lang}),
    };
  }

  /**
   * @description Return invoice translation keys object
   * @param params
   * @returns {*}
   */
  getInvoice(params = []) {
    const keys = {
      invoice_header_title: this.getPhrases('invoice').header_title,
      invoice_header_subject: this.getPhrases('invoice').header_subject,
      invoice_header_reference: this.getPhrases('invoice').header_reference,
      invoice_header_reference_value: this.getReferenceFromPattern('invoice'),
      invoice_header_date: this.getPhrases('invoice').header_date,
      table_note_content: this.invoice_note,
      note: (note) => ((note) ? this.invoice_note = note : this.invoice_note),
      filename: 'invoice',
    };
    params.forEach((phrase) => {
      if (typeof phrase === 'string') {
        keys[phrase] = i18n.__({ phrase, locale: this.lang });
      } else if (typeof phrase === 'object' && phrase.key && phrase.value) {
        keys[phrase.key] = phrase.value;
      }
    });

    return Object.assign(keys, {
      toHTML: () => this._toHTML(keys, params),
      toPDF: () => this._toPDF(keys, params),
    }, this._preCompileCommonTranslations());
  }

  /**
   * @description Return order translation keys object
   * @param params
   * @returns {*}
   */
  getOrder(params = []) {
    const keys = {
      order_header_title: this.getPhrases('order').header_title,
      order_header_subject: this.getPhrases('order').header_subject,
      order_header_reference: this.getPhrases('order').header_reference,
      order_header_reference_value: this.getReferenceFromPattern('order'),
      order_header_date: this.getPhrases('order').header_date,
      table_note_content: this.order_note,
      note: (note) => ((note) ? this.order_note = note : this.order_note),
      filename: 'order',
    };
    params.forEach((phrase) => {
      if (typeof phrase === 'string') {
        keys[phrase] = i18n.__({ phrase, locale: this.lang });
      } else if (typeof phrase === 'object' && phrase.key && phrase.value) {
        keys[phrase.key] = phrase.value;
      }
    });

    return Object.assign(keys, {
      toHTML: () => this._toHTML(keys, params),
      toPDF: () => this._toPDF(keys, params),
    }, this._preCompileCommonTranslations());
  }

  /**
   * @description Return right footer
   * @returns {*}
   */
  getFooter() {
    if (!this.footer) return i18n.__({phrase: 'footer', locale: this.lang});

    if (this.lang === 'en') return this.footer.en;
    if (this.lang === 'fr') return this.footer.fr;
    throw Error('This lang doesn\'t exist.');
  }

  /**
   * @description Return reference from pattern
   * @param type
   * @return {*}
   */
  getReferenceFromPattern(type) {
    if (!['order', 'invoice'].includes(type)) throw new Error('Type have to be "order" or "invoice"');
    if (this.reference) return this.reference;
    return this.setReferenceFromPattern((type === 'order') ? this.order_reference_pattern : this.invoice_reference_pattern);
  }

  /**
   * @description Set reference
   * @param pattern
   * @return {*}
   * @private
   * @todo optimize it
   */
  setReferenceFromPattern(pattern) {
    const tmp = pattern.split('$').slice(1);
    let output = '';
    // eslint-disable-next-line no-restricted-syntax
    for (const item of tmp) {
      if (!item.endsWith('}')) throw new Error('Wrong pattern type');
      if (item.startsWith('prefix{')) output += item.replace('prefix{', '').slice(0, -1);
      else if (item.startsWith('separator{')) output += item.replace('separator{', '').slice(0, -1);
      else if (item.startsWith('date{')) output += moment().format(item.replace('date{', '').slice(0, -1));
      else if (item.startsWith('id{')) {
        const id = item.replace('id{', '').slice(0, -1);
        if (!/^\d+$/.test(id)) throw new Error(`Id must be an integer (${id})`);
        output += (this._id) ? this.pad(this._id, id.length) : this.pad(0, id.length);
      } else throw new Error(`${item} pattern reference unknown`);
    }
    return output;
  }

  /**
   * @description Export object with html content and exportation functions
   * @param keys
   * @param params
   * @returns {{html: *, toFile: (function(*): *)}}
   * @private
   */
  _toHTML(keys, params = []) {
    const html = this._compile(keys.filename === 'order' ? this.getOrder(params) : this.getInvoice(params));
    return {
      html,
      toFile: (filepath) => this._toFileFromHTML(html, (filepath) || `${keys.filename}.html`),
    };
  }

  /**
   * @description Save content to pdf file
   * @param keys
   * @param params
   * @returns {*}
   * @private
   */
  _toPDF(keys, params = []) {
    const pdf = htmlPDF.create(this._toHTML(keys, params).html, {timeout: '90000'});
    return {
      pdf,
      toFile: (filepath) => this._toFileFromPDF(pdf, (filepath) || `${keys.filename}.pdf`),
      toBuffer: () => this._toBufferFromPDF(pdf),
      toStream: (filepath) => this._toStreamFromPDF(pdf, (filepath) || `${keys.filename}.pdf`),
    };
  }

  /**
   * @description Save content into file from toHTML() method
   * @param content
   * @param filepath
   * @returns {Promise}
   * @private
   */
  _toFileFromHTML(content, filepath) {
    return new Promise((resolve, reject) => {
      fs.writeFile(filepath, content, (err) => {
        if (err) reject(err);
        return resolve();
      });
    });
  }

  /**
   * @description Save content into file from toPDF() method
   * @param content
   * @param filepath
   * @returns {Promise}
   * @private
   */
  _toFileFromPDF(content, filepath) {
    return new Promise((resolve, reject) => {
      content.toFile(filepath, (err, res) => {
        if (err) return reject(err);
        return resolve(res);
      });
    });
  }

  /**
   * @description Export PDF to buffer
   * @param content
   * @returns {*}
   * @private
   */
  _toBufferFromPDF(content) {
    return new Promise((resolve, reject) => {
      content.toBuffer((err, buffer) => {
        if (err) return reject(err);
        return resolve(buffer);
      });
    });
  }

  /**
   * @description Export PDF to file using stream
   * @param content
   * @param filepath
   * @returns {*}
   * @private
   */
  _toStreamFromPDF(content, filepath) {
    return content.toStream((err, stream) => stream.pipe(fs.createWriteStream(filepath)));
  }

  /**
   * @description Calculates number of pages and items per page
   * @return {{rows_in_first_page: number, rows_in_others_pages: number, loop_table: number}}
   * @private
   */
  _templateConfiguration() {
    const template_rows_per_page = 29;
    const templateConfig = {
      rows_in_first_page: (this.article.length > 19) ? template_rows_per_page : 19,
      rows_per_pages: 43,
      rows_in_last_page: 33,
    };

    let nbArticles = this.article.length;
    let loop = 1;
    while (true) {
      if (loop === 1) {
        nbArticles -= templateConfig.rows_in_first_page;
        if (nbArticles <= 0) {
          templateConfig.loop_table = (templateConfig.rows_in_first_page !== template_rows_per_page) ? 1 : 2;
          return templateConfig;
        }
      }

      if (loop >= 2) {
        if (nbArticles <= templateConfig.rows_in_last_page) {
          templateConfig.loop_table = loop;
          return templateConfig;
        }
        nbArticles -= templateConfig.rows_per_pages;
        if (nbArticles <= 0) {
          templateConfig.loop_table = loop;
          return templateConfig;
        }
      }
      loop += 1;
    }
  }

  /**
   * @description Overrides i18n configuration
   * @param config
   * @private
   */
  _i18nConfigure(config) {
    this._defaultLocale = (config && config.defaultLocale) ? config.defaultLocale : 'en';
    this._availableLocale = (config && config.locales) ? config.locales : ['en', 'fr'];
    if (config) i18n.configure(config);
  }
}