Source: index.js

var fs = require('fs-extra-promise');
var request = require('request-promise-native');
var uuid = require('uuid');
var path = require('path');
var moment = require('moment');
var mime = require('mime/lite');
var debugMod = require('debug');
var createLRU = require("lru-cache");

var __cache = createLRU({ maxAge: 1000 * 60 * 60 * 15 /* 15 minutes */ });
var __apiTokenVar = Symbol();

/**
 * The main voucher importer class.
 */
class SevdeskVoucherImporter {

    /** 
     * Imports a single local file. The file is most likely a PDF or image file.
     * 
     * @param {string} filePath Path to the local file to be imported.
     * @returns {Promise<void>} An empty promise is returned.
     * @throws {Error} An error happened during the import. Inner exceptions are not being wrapped.
     */
    async importLocalFile(filePath) {
        // lock the object
        if (this.locked)
            throw new Error("SevdeskVoucherImporter object already used; the object cannot be reused");
        
        //this.locked = true;  // do not lock, yet

        // check the file
        this.debug(`Checking file: ${filePath} ...`);

        if (!filePath)
            throw new Error("No file provided; missing parameter 'filePath'");

        if (!(await fs.existsAsync(filePath)))
            throw new Error(`File does not exist: ${filePath}`);

        if (!(await fs.statAsync(filePath)).isFile())
            throw new Error(`Path exists but is not a file: ${filePath}`);

        // load the data
        let data = await fs.readFileAsync(filePath);

        return this.importBuffer(data, path.basename(filePath));
    }

    /** 
     * Imports a PDF or image file from a buffer. The filename is provided separately.
     * 
     * @param {Buffer} data The content of the file to be uploaded as buffer.
     * @param {string} filename The file name of the file to be uploaded. (Needed for mime type detection.)
     * @returns {Promise<void>} An empty promise is returned.
     * @throws {Error} An error happened during the import. Inner exceptions are not being wrapped.
     */
    async importBuffer(data, filename) {
        // lock the object
        if (this.locked)
            throw new Error("SevdeskVoucherImporter object already used; the object cannot be reused");
        
        this.locked = true;

        // check parameters
        if (!Buffer.isBuffer(data))
            throw new Error("No data provided; missing parameter 'data'");

        if (!filename)
            throw new Error("No file name provided; missing parameter 'filename'");

        // load client information
        await this.loadClientInfo();

        // upload the file
        this.debug(`Uploading file: ${filename} ...`);

        let res = await request({
            method: 'POST',
            uri: `${this.baseUrl}/Voucher/Factory/uploadTempFile`,
            qs: {
                cft: this.cft,
                token: this[__apiTokenVar],
            },
            formData: {
                file: {
                    value: data,
                    options: {
                        filename: filename,
                        contentType: mime.getType(filename),
                    }
                }
            },
            headers: {
                'Accept': 'application/json',
                'Pragma': 'no-cache',
                'Cache-Control': 'no-cache',
            },
            json: true,
            gzip: true,
        });

        // retrieve the filename
        if (!res.objects || !res.objects.filename)
            throw new Error(`Failed to extract filename from response: ${JSON.stringify(res)}`);

        let remoteFilename = res.objects.filename;

        this.debug(`Successfully uploaded as ${remoteFilename}`);

        // extract information
        this.debug("Extracting information...");

        res = await request({
            method: 'GET',
            uri: `${this.baseUrl}/Voucher/Factory/extractThumb`,
            qs: {
                cft: this.cft,
                token: this[__apiTokenVar],
                fileName: remoteFilename,
            },
            headers: {
                'Accept': 'application/json',
                'Pragma': 'no-cache',
                'Cache-Control': 'no-cache',
            },
            json: true,
            gzip: true,
        });

        if (!res.objects || !Array.isArray(res.objects.extractions))
            throw new Error(`Failed to extract information from response: ${JSON.stringify(res)}`);

        let resultDisdar = res; // save for later

        // transform the information
        let extractions = {};
        
        for (let ex of res.objects.extractions) {
            if (!Array.isArray(ex.labels)) continue;

            for (let l of ex.labels) {
                if (!extractions[l.type] || (extractions[l.type].confidence < l.confidence))
                    extractions[l.type] = { ...l, type: undefined };
            }
        }

        this.debug(`Successfully Extracted information: ${Object.keys(extractions).map(k => `${k}="${extractions[k].value}"`).join(", ")}`);

        // load all contacts for extended resolution
        await this.loadAllContacts();

        // determine issuer contact
        // (highest priority is checked first)
        this.issuerContacts = [];

        // try to find the exact name
        if (extractions.CREDITORNAME) {
            await this.findContactByName(extractions.CREDITORNAME.value);        
        } else {
            this.debug("Skipping resolving issuer contact by exact name; name is not known");
        }

        // try to find the bank account
        if (extractions.IBAN) {
            await this.findContactByBankAccount(extractions.IBAN.value);        
        } else {
            this.debug("Skipping resolving issuer contact by bank account; IBAN is not known");
        }

        // try to find by first word
        if (extractions.CREDITORNAME) {
            let nameParts = extractions.CREDITORNAME.value.split(' ', 2);

            if (nameParts.length >= 2) // only do this if there are at least two words
                await this.findContactByName(nameParts[0]);
        
        } else {
            this.debug("Skipping resolving issuer contact by first word of name; name is not known");
        }

        // estimate the accounting type
        let accountingType = null;

        if (this.issuerContacts.length > 0) {
            accountingType = await this.estimateAccountingType(this.issuerContacts[0], extractions);
        }

        // save the voucher
        this.debug("Saving voucher...");

        let formData = {
            'voucher[voucherDate]': String(extractions.INVOICEDATE ? extractions.INVOICEDATE.value : moment().format('YYYY-MM-DD')),
            'voucher[description]': String(extractions.INVOICENUMBER ? extractions.INVOICENUMBER.value : null),
            'voucher[resultDisdar]': JSON.stringify(resultDisdar),
            'voucher[status]': '50' /* draft */,
            'voucher[taxType]': 'default',
            'voucher[creditDebit]': 'C',
            'voucher[voucherType]': 'VOU',
            'voucher[iban]:': String(extractions.IBAN ? extractions.IBAN.value : null),
            'voucher[tip]': '0',
            'voucher[mileageRate]': '0',
            'voucher[selectedForPaymentFile]': '0',
            'voucher[objectName]': 'Voucher',
            'voucher[mapAll]': 'true',
            'voucherPosSave[0][taxRate]': String(extractions.TAXRATE ? extractions.TAXRATE.value : 0 /* cannot be null */),
            'voucherPosSave[0][sum]': String(extractions.NETAMOUNT ? Number.parseInt(extractions.NETAMOUNT.value) / 100 : 0),
            'voucherPosSave[0][objectName]': 'VoucherPos',
            'voucherPosSave[0][mapAll]': 'true',
            'voucherPosDelete': 'null',
            'filename': String(remoteFilename),
            'existenceCheck': 'false',
        };

        // inject issuer/creditor
        if (this.issuerContacts.length > 0) {
            formData = { 
                ...formData, 
                'voucher[supplier][id]': String(this.issuerContacts[0].id),
                'voucher[supplier][objectName]': 'Contact',
                'voucher[supplierMethod]': String(this.issuerContacts[0].method),
            };
        } else {
            formData = { 
                ...formData, 
                'voucher[supplierName]': String(extractions.CREDITORNAME ? extractions.CREDITORNAME.value : "???"),
            };
        }

        // inject accounting type
        if (accountingType) {
            formData = { 
                ...formData, 
                'voucherPosSave[0][accountingType][id]': String(accountingType),
                'voucherPosSave[0][accountingType][objectName]': 'AccountingType',
                'voucherPosSave[0][estimatedAccountingType][id]': String(accountingType),
                'voucherPosSave[0][estimatedAccountingType][objectName]': 'AccountingType',
            };
        }

        // perform save
        res = await request({
            method: 'POST',
            uri: `${this.baseUrl}/Voucher/Factory/saveVoucher`,
            qs: {
                cft: this.cft,
                token: this[__apiTokenVar],
            },
            headers: {
                'Accept': 'application/json',
                'Pragma': 'no-cache',
                'Cache-Control': 'no-cache',
            },
            form: formData,
            json: true,
            gzip: true,
        });

        // the response can be different
        // perform some general validation, first
        if (!res.objects)
            throw new Error(`Failed to extract document from response [1]: ${JSON.stringify(res)}`);

        // handle the array and non-array version
        let resObj = null;

        if (Array.isArray(res.objects)) {
            if (res.objects.length < 1)
                throw new Error(`Failed to extract document from response [2]: ${JSON.stringify(res)}`);

            resObj = res.objects[0];
        } else {
            if (typeof res.objects !== 'object')
                throw new Error(`Failed to extract document from response [3]: ${JSON.stringify(res)}`);

            resObj = res.objects;
        }
        
        // handle the result document
        if (!resObj.document || !resObj.document.id)
            throw new Error(`Failed to extract document from response [4]: ${JSON.stringify(resObj)}`);

        this.newDocumentId = parseInt(resObj.document.id);

        this.debug(`Successfully saved voucher: ${this.newDocumentId}`);
    }

    async findContactByBankAccount(bankAccount) {
        // prepare
        bankAccount = bankAccount.replace(/[\t -]/g, '');

        this.debug(`Resolving issuer contact by bank account: ${bankAccount} ...`);

        // is this the clients' bank account?
        if (this.clientInfo.bankIban && bankAccount.match(new RegExp(`^${this.clientInfo.bankIban}$`, 'i'))) {
            this.debug(`Ignoring bank account because it belong to the client: ${bankAccount}`);
            return;
        }

        // look for exact match
        let found = this.allContacts.filter(o => o.bankAccount && o.bankAccount.replace(/[\t -]/g, '').match(new RegExp(`^${bankAccount}$`, 'i')));

        if (found.length <= 0) {
            this.debug(`No issuer contact was found by bank account: ${bankAccount}`);
        } else {
            for (let c of found) {
                this.debug(`Found issuer contact by bank account: ${c.name} (#${c.id})`);

                this.issuerContacts.push({ ...c, method: "bankAccountExact" });
            }
        }
    }

    async findContactByName(name) {
        this.debug(`Resolving issuer contact by name: ${name} ...`);

        // is this the clients' name?
        if (this.clientInfo.name && name.match(new RegExp(`^${this.clientInfo.name}$`, 'i'))) {
            this.debug(`Ignoring contact because it belongs to the client: ${name}`);
            return;
        }

        // look for exact name match
        let found = this.allContacts.filter(o => o.name && o.name.match(new RegExp(`^${name}$`, 'i')));

        if (found.length <= 0) {
            this.debug(`No issuer contact was found by exact name: ${name}`);
        } else {
            for (let c of found) {
                this.debug(`Found issuer contact by exact name: ${c.name} (#${c.id})`);

                this.issuerContacts.push({ ...c, method: "nameExact" });
            }
        }

        // look for partial name match
        found = this.allContacts.filter(o => o.name && o.name.match(new RegExp(`${name}`, 'i')));

        if (found.length <= 0) {
            this.debug(`No issuer contact was found by partial name: ${name}`);
        } else {
            for (let c of found) {
                this.debug(`Found issuer contact by partial name: ${c.name} (#${c.id})`);

                this.issuerContacts.push({ ...c, method: "namePartial" });
            }
        }

        // look for partial name2 match
        found = this.allContacts.filter(o => o.name2 && o.name2.match(new RegExp(`${name}`, 'i')));

        if (found.length <= 0) {
            this.debug(`No issuer contact was found by partial name2: ${name}`);
        } else {
            for (let c of found) {
                this.debug(`Found issuer contact by partial name2: ${c.name} (#${c.id})`);

                this.issuerContacts.push({ ...c, method: "name2Partial" });
            }
        }
    }

    async loadAllContacts() {
        // already loaded? 
        if (Array.isArray(this.allContacts)) 
            return;

        await this.loadClientInfo();

        this.debug(`Loading all contacts...`);

        // cached?
        let cached = __cache.get(`${this.clientInfo.id}:allContacts`);

        if (cached) {
            this.allContacts = cached;
            this.allContactsFromCache = true;
            
            this.debug(`Successfully retrieved ${this.allContacts.length} contacts from cache`);
            return;
        }

        // prepare
        this.allContacts = [];
        this.allContactsFromCache = false;

        let res = null;
        let offset = 0;

        do {
            // load a bunch of contacts
            res = await request({
                method: 'GET',
                uri: `${this.baseUrl}/Contact`,
                qs: {
                    cft: this.cft,
                    token: this[__apiTokenVar],
                    depth: true,
                    limit: 100,
                    offset: offset,
                    'orderBy[0][field]': 'create',  // 'id' field does not work
                    'orderBy[0][arrangement]': 'asc',
                },
                headers: {
                    'Accept': 'application/json',
                    'Pragma': 'no-cache',
                    'Cache-Control': 'no-cache',
                },
                json: true,
                gzip: true,
            });
    
            if (!Array.isArray(res.objects))
                throw new Error(`Failed to get contacts from response: ${JSON.stringify(res)}`);

            offset += res.objects.length;
    
            this.allContacts = this.allContacts.concat(res.objects);
    
        } while (res.objects.length > 0);
    
        this.debug(`Successfully loaded ${this.allContacts.length} contacts`);

        __cache.set(`${this.clientInfo.id}:allContacts`, this.allContacts);

        //this.debug(JSON.stringify(this.allContacts[0]));
    }

    async loadClientInfo() {
        // already loaded? 
        if (this.clientInfo) 
            return;

        this.debug(`Loading client information...`);

        let res = await request({
            method: 'GET',
            uri: `${this.baseUrl}/SevClient`,
            qs: {
                cft: this.cft,
                token: this[__apiTokenVar],
            },
            headers: {
                'Accept': 'application/json',
                'Pragma': 'no-cache',
                'Cache-Control': 'no-cache',
            },
            json: true,
            gzip: true,
        });

        if (!Array.isArray(res.objects) || (res.objects.length < 1))
            throw new Error(`Failed to get client information from response: ${JSON.stringify(res)}`);

        this.clientInfo = res.objects[0];

        this.debug(`Client ID: ${this.clientInfo.id}`);

        //this.debug(JSON.stringify(this.clientInfo));
    }

    async estimateAccountingType(contact, extractions) {
        if (!contact)
            throw new Error("no contact provided; missing parameter 'contact'");

        if (!extractions)
            throw new Error("no extracted information provided; missing parameter 'extractions'");

        await this.loadClientInfo();

        let contactAddress = await this.loadContactAddress(contact.id);

        this.debug(`Estimating accounting type for contact ${contact.name} (#${contact.id})...`);

        let res = await request({
            method: 'GET',
            uri: `${this.baseUrl}/AccountingIndex/Query/estimateType`,
            qs: {
                cft: this.cft,
                token: this[__apiTokenVar],
                jsonData: JSON.stringify({
                    sev_client: this.clientInfo.id,
                    credit_debit: 'C',
                    industry: null,
                    address_country: this.clientInfo.addressCountry.id,
                    form_of_company: this.clientInfo.formOfCompany,
                    company_size: null,
                    small_settlement: false,
                    sum_net: (extractions.NETAMOUNT ? parseInt(extractions.NETAMOUNT.value) : null),
                    sum_tax: (extractions.TAXRATE ? parseInt(extractions.TAXRATE.value) : null),
                    supplier: contact.id,
                    supplier_name: contact.name,
                    supplier_country: (contactAddress ? contactAddress.country.id : null),
                    id_accounting_type: null,
                }),
            },
            headers: {
                'Accept': 'application/json',
                'Pragma': 'no-cache',
                'Cache-Control': 'no-cache',
            },
            json: true,
            gzip: true,
        });

        if (!res.objects)
            throw new Error(`Failed to get estimated accounting type from response: ${JSON.stringify(res)}`);

        // handle the result
        if (Object.keys(res.objects).indexOf(this.clientInfo.chartOfAccounts) > 0) {
            let code = res.objects[this.clientInfo.chartOfAccounts];

            this.debug(`Estimated accounting type: ${this.clientInfo.chartOfAccounts} ${code} - ${res.objects.name} (#${res.objects.id})`);
        } else {
            this.debug(`Estimated accounting type: ${res.objects.name} (#${res.objects.id})`);
        }

        //this.debug(JSON.stringify(res.objects));

        return res.objects.id;
    }

    async loadContactAddress(contactId) {
        if (!contactId)
            throw new Error("no contact provided; missing parameter 'contactId'");

        this.debug(`Loading contact address for contact #${contactId}...`);

        // cached?
        let cached = __cache.get(`${this.clientInfo.id}:contact:${contactId}`);

        if (cached) {
            this.debug(`Successfully retrieved contact address for contact #${contactId} from cache`);
            
            return cached;
        }

        // load the address
        let res = await request({
            method: 'GET',
            uri: `${this.baseUrl}/Contact/${contactId}/getMainAddress`,
            qs: {
                cft: this.cft,
                token: this[__apiTokenVar],
            },
            headers: {
                'Accept': 'application/json',
                'Pragma': 'no-cache',
                'Cache-Control': 'no-cache',
            },
            json: true,
            gzip: true,
        });
        
        if (!res.objects)
            throw new Error(`Failed to get contact address from response: ${JSON.stringify(res)}`);

        __cache.set(`${this.clientInfo.id}:contact:${contactId}`, res.objects);

        this.debug(`Successfully loaded contact address for contact #${contactId}`);

        return res.objects;
    }

    /**
     * Creates a new instance of the importer class.
     * The instance can be used for a single import process.
     * 
     * @param {string} apiToken The sevDesk API Token to be used
     */
    constructor(apiToken) {
        if (!apiToken)
            throw new Error("No API token provided; missing parameter 'apiToken'");

        this[__apiTokenVar] = apiToken;
        this.baseUrl = "https://my.sevdesk.de/api/v1";
        this.locked = false;
        this.allContacts = null;
        this.allContactsFromCache = null;
        this.clientInfo = null;

        this.cft = uuid.v4().replace(/-/g, '');
        this.debug = debugMod(`sevDesk:voucherImporter:${this.cft}`);

        /**
         * After the import process is complete, contains the sevDesk ID of the newly created document.
         * Is null on error or default.
         * @type {number | null}
         */
        this.newDocumentId = null;
    }
}


module.exports = SevdeskVoucherImporter;