/** * Controller to manage the invoices functions */ 'use strict'; const _ = require('lodash'); const Q = require('q'); const httpStatus = require('http-status-codes'); const mongodb = require('mongodb'); const templates = require(global.pathPrefix + '../utils/templates.js'); const debug = require('debug')('webconsole-api:controllers:invoices'); const mainDB = require(global.pathPrefix + 'mainDB.js'); const utils = require(global.pathPrefix + 'utils.js'); const mailer = require(global.pathPrefix + 'mailer.js'); const valid = require(global.pathPrefix + 'valid.js'); const swaggerUtils = require(global.pathPrefix + '../utils/swaggerUtils.js'); const apiHelpers = require(global.pathPrefix + '../utils/api_helpers.js'); const formattingUtils = require(global.pathPrefix + '../utils/formatting.js'); const references = require(global.pathPrefix + '../utils/references.js'); const config = require(global.configFile); module.exports = { getInvoices, getInvoice, addInvoice, updateInvoice, cancelInvoice }; /** * Define a constant for the valid "invoice" transaction statuses */ const INVOICE_TRANSACTION_STATUSES = [20, 21, 22]; /** * Definition for the renames we use between "Transactions" and "Invoices" */ const INVOICE_TO_TRANSACTION = { _id: 'InvoiceID', CustomerClientName: 'CustomerEmail', TransactionStatus: 'InvoiceStatus' }; /** * Validation errors */ const ERRORS = { NO_MERCHANT_ACCOUNT: 'BRIDGE: Merchant account not found', NO_CUSTOMER: 'BRIDGE: Customer not found', INSERT_INVOICE_INVALID_NUMBER: 'BRIDGE: Failed to find a valid invoice number' }; /** * Get the invoice list * * @param {Object} req - Express request object * @param {Object} res - Express response object */ function getInvoices(req, res) { // // Check that the client is a merchant // if (!req.session.data.isMerchant) { res.status(httpStatus.FORBIDDEN).json({ code: 30701, info: 'Not a merchant' }); return; } // // Get the query params from the request and the session // const clientID = req.session.data.clientID; // // Define the query according to the params // const query = { MerchantClientID: clientID, TransactionStatus: { $in: INVOICE_TRANSACTION_STATUSES } }; // // Define the projection based on the Swagger definition // const projection = swaggerUtils.swaggerToMongoProjection( req.swagger.operation, true, // include _id so we know how to select an individual invoice. undefined, // No subdocument INVOICE_TO_TRANSACTION // Renames ); // // Make the query. Note limit & skip have defaults defined in the // swagger definition, so will always exist even if not requested // mainDB.collectionTransaction.find(query) .project(projection) .sort({LastUpdate: -1}) // Hard-coded reverse sort by time .toArray((err, invoices) => { if (err) { debug('- failed to getInvoices', err); res.status(httpStatus.BAD_GATEWAY).json({ code: 30702, info: 'Database offline' }); } else { // // Rename _id to InvoiceID before returning them // Rename CustomerClientName to CustomerEmail // apiHelpers.renameFields(invoices, INVOICE_TO_TRANSACTION); // // Null any nullable fields // swaggerUtils.getAndApplyNullableFields(req.swagger.operation, invoices); // // Move invoice number to the top level // Fix any missing due dates // for (let i = 0; i < invoices.length; ++i) { if (!_.isUndefined(invoices[i].MerchantInvoiceNumber)) { invoices[i].MerchantInvoiceNumber = invoices[i].MerchantInvoiceNumber.InvoiceNumber; } if (_.isUndefined(invoices[i].DueDate)) { invoices[i].DueDate = '1970-01-01T00:00:00.000Z'; } } res.status(httpStatus.OK).json(invoices); } }); } /** * Gets the invoice details for a specific invoice. * * @param {Object} req - Express request object * @param {Object} res - Express response object */ function getInvoice(req, res) { // // Check that the client is a merchant // if (!req.session.data.isMerchant) { res.status(httpStatus.FORBIDDEN).json({ code: 30703, info: 'Not a merchant' }); return; } // // Get the query params from the request and the session // const clientID = req.session.data.clientID; const invoiceId = req.swagger.params.objectId.value; // // Build the query. The limits are: // - Must match the id of the invoice we are looking for // - Current user must be the invoice owner (to protect against Insecure // Direct Object References). // const query = { _id: mongodb.ObjectID(invoiceId), MerchantClientID: clientID, TransactionStatus: { $in: INVOICE_TRANSACTION_STATUSES } }; // // Define the fields based on the Swagger definition. // Need to also request the CustomerClientName // const projection = swaggerUtils.swaggerToMongoProjection( req.swagger.operation, true, undefined, // No subdocument INVOICE_TO_TRANSACTION // Renames ); // // Add the CustomerClientID so we can find out the email address later // projection.CustomerClientID = 1; // // Build the options to encapsulate the projection // const options = { fields: projection, comment: 'WebConsole:getInvoice' // For profiler logs use }; // // Make the request // mainDB.findOneObject(mainDB.collectionTransaction, query, options, false, (err, invoice) => { if (err) { debug('- failed to getInvoice', err); res.status(httpStatus.BAD_GATEWAY).json({ code: 30704, info: 'Database offline' }); } else if (invoice === null) { // // Nothing found // res.status(httpStatus.NOT_FOUND).json({ code: 30705, info: 'Not found' }); } else { // // Get the email address for the client // const emailP = references.getEmailAddress(invoice.CustomerClientID); delete invoice.CustomerClientID; // // Add a creation date field from the _id // invoice.CreationDate = invoice._id.getTimestamp(); // // Rename fields // apiHelpers.renameFields(invoice, INVOICE_TO_TRANSACTION); // // Null any nullable fields // swaggerUtils.getAndApplyNullableFields(req.swagger.operation, invoice); // // Move the invoice number to the top level, and fix potentially // missing due date // if (!_.isUndefined(invoice.MerchantInvoiceNumber)) { invoice.MerchantInvoiceNumber = invoice.MerchantInvoiceNumber.InvoiceNumber; } if (_.isUndefined(invoice.DueDate)) { invoice.DueDate = '1970-01-01T00:00:00.000Z'; } // // Wait for the client email address and complete the invoice // emailP.then((email) => { invoice.CustomerEmail = email; return res.status(httpStatus.OK).json(invoice); }).catch((error) => { if (error.name && error.name === references.ERRORS.INVALID_CLIENT) { // No customer email, so just send it without one. // This will allow the merchant to update the customer, cancel it, etc. res.status(httpStatus.OK).json(invoice); } else { debug('- failed to get customer email', error); res.status(httpStatus.BAD_GATEWAY).json({ code: 30704, info: 'Database offline' }); } }); } }); } /** * Adds a new invoice. * * @param {Object} req - Express request object * @param {Object} res - Express response object */ function addInvoice(req, res) { // // Check that the client is a merchant // if (!req.session.data.isMerchant) { res.status(httpStatus.FORBIDDEN).json({ code: 30706, info: 'Not a merchant' }); return; } const merchantID = req.session.data.clientID; const validatedInvoice = req.swagger.params.body.value; // // Step 0: Validate the merchant invoice values add up correctly // const INVALID_MERCHANT_INVOICE = 'BRIDGE: MerchantInvoice items are not valid'; let invoiceValidationP = Q.resolve(); if (_.isArray(validatedInvoice.MerchantInvoice)) { /** * Validate the invoice, allowing for items to be repeated */ const result = valid.validateFieldMerchantInvoice( validatedInvoice.MerchantInvoice, validatedInvoice.RequestAmount, true ); if (result) { invoiceValidationP = Q.reject({name: INVALID_MERCHANT_INVOICE}); } } // // Step 1: Get the merchant's client details // const NO_MERCHANT = 'BRIDGE: Merchant not found'; const findMerchantQuery = { ClientID: merchantID }; const findMerchantOptions = { comment: 'webconsole: addInvoice validate merchant' }; const findMerchantPromise = Q.nfcall( mainDB.findOneObject, mainDB.collectionClient, findMerchantQuery, findMerchantOptions, false // Don't suppress errors ).then((merchant) => { return merchant ? merchant : Q.reject({name: NO_MERCHANT}); }); // // Step 2: Validate the merchant's account ID // const findAccountPromise = validateMerchantAccount( merchantID, validatedInvoice.MerchantAccountID ); // // Step 3: Validate the customer. // const findCustomerPromise = validateCustomer(validatedInvoice.CustomerEmail); // // Step 4: Check everything is valid, so now we create the Transactions we are // going to make. // const addPromise = Q.all([ invoiceValidationP, findMerchantPromise, findAccountPromise, findCustomerPromise]).then((results) => { const merchantDetails = results[1]; const customer = results[3]; // // Create a blank transaction, then update it with our specific values // const newTransaction = mainDB.blankTransaction(); _.assignWith( newTransaction, { CustomerClientID: customer.ClientID, CustomerDisplayName: customer.DisplayName, CustomerImage: customer.Selfie, MerchantSessionToken: req.session.id, MerchantAccountID: validatedInvoice.MerchantAccountID, DueDate: validatedInvoice.DueDate, MerchantClientID: merchantID, MerchantDisplayName: merchantDetails.Merchant[0].CompanyAlias, MerchantSubDisplayName: merchantDetails.Merchant[0].CompanySubName, MerchantImage: merchantDetails.Merchant[0].CompanyLogo, MerchantVATNo: merchantDetails.Merchant[0].VATNo, MerchantInvoice: validatedInvoice.MerchantInvoice, MerchantComment: validatedInvoice.MerchantComment, TransactionStatus: utils.TransactionStatus.PENDING_INVOICE, StatusInfo: 'Pending Invoice', RequestAmount: validatedInvoice.RequestAmount, LastUpdate: new Date(), // Invoice Numbering MerchantInvoiceNumber: { InvoiceNumber: 1, MerchantID: merchantID, MerchantIndex: 0 // Always 0 at present, but allows future support } }, (objectValue, sourceValue) => { /* Only merge values that aren't received as undefined */ return _.isUndefined(sourceValue) ? objectValue : sourceValue; } ); // // Built up the transaction so add it. // return addMonotonicallyNumberedInvoice( newTransaction, config.maxInvoiceNumberAttempts ); }); // // Step 4. Run all the promises and wait for the result // Q.all([findMerchantPromise, findAccountPromise, findCustomerPromise, addPromise]) .then((result) => { // // Succeeded // The _id is in result[2][0] because: // Result is an array of results from the 3 promises in .all() // Thus result[2] is the result of addPromise // This is an addObject() which returns an array itself. But we // are only adding one so we know it is result[2][0]. // const insertedInvoice = result[3][0]; res.status(201).json({ InvoiceID: insertedInvoice._id }); // // Send an email to the customer // Note that we are not going to let the success/failure affect // the success of adding an invoice // return notifyNewInvoice(validatedInvoice.CustomerEmail, insertedInvoice); }) .catch((error) => { debug('-- error adding invoice: ', error); if ( error && error.hasOwnProperty('name') ) { switch (error.name) { case ERRORS.NO_MERCHANT_ACCOUNT: res.status(httpStatus.CONFLICT).json({ code: 30708, info: 'Invalid Merchant Account' }); break; case ERRORS.NO_CUSTOMER: res.status(httpStatus.CONFLICT).json({ code: 30709, info: 'Customer not found' }); break; case ERRORS.INSERT_INVOICE_INVALID_NUMBER: res.status(httpStatus.CONFLICT).json({ code: 30718, info: 'Unable to find a valid invoice number.' }); break; case 'MongoError': res.status(httpStatus.BAD_GATEWAY).json({ code: 30707, info: 'Database Unavailable' }); break; case INVALID_MERCHANT_INVOICE: res.status(httpStatus.BAD_REQUEST).json({ code: 30718, info: 'Invalid MerchantInvoice values' }); break; case NO_MERCHANT: // This should never happen as we have a session default: // // Unknown error // res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ code: -1, info: 'Unexpected error' }); break; } } else { // // Unknown error // res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ code: -1, info: 'Unexpected error' }); } }); } /** * This attempts to add a new invoice with an ID that is monotonically * increasing for each invoice. As we don't have db transactions we can't * do something like have a merchant-level counter we increment and apply to * invoices. * * Instead we have to use an "optimistic loop" together with a unique index on * the collection. See: * https://docs.mongodb.com/v3.0/tutorial/create-an-auto-incrementing-field/#auto-increment-optimistic-loop * * The basic operation is: * 1. Query for the highest current invoice number * 2. Add 1 to it, and try and insert document with that invoice number * 3. If (2) fails due to duplicate index THEN goto 1 (e.g. race condition with * another process which is also adding invoices) * 4. If (2) fails times then report an error (overloading or similar). * * @param {Object} newInvoice - the new invoice we want to insert * @param {integer} maxAttempts - the max attempts we can make * * @returns {Promise} - a promise that resolves when the operation completes * or rejects on failure */ function addMonotonicallyNumberedInvoice(newInvoice, maxAttempts) { debug('addMonotonicallyNumberedInvoice', maxAttempts, newInvoice.MerchantInvoiceNumber.MerchantID); // // Have we run out of attempts? // if (maxAttempts === 0) { return Q.reject({name: ERRORS.INSERT_INVOICE_INVALID_NUMBER}); } // // Try and find a valid number to insert with // const query = { 'MerchantInvoiceNumber.MerchantID': newInvoice.MerchantInvoiceNumber.MerchantID, 'MerchantInvoiceNumber.MerchantIndex': newInvoice.MerchantInvoiceNumber.MerchantIndex }; const sortOrder = { 'MerchantInvoiceNumber.InvoiceNumber': -1 }; const projection = { MerchantInvoiceNumber: 1 }; // // Run the query to find the largest number // debug('addMonotonicallyNumberedInvoice: finding: ', query, projection); const cursor = mainDB.collectionTransaction .find(query, projection) .sort(sortOrder) .limit(1); const findPromise = cursor.next(); // // Use the result of that query to try and insert a new invoice. // This could fail if we are racing another insertion, so we need to expect // that posibility, and try again // const insertPromise = findPromise.then((result) => { debug('- addMonotonicallyNumberedInvoice: found:', result); let nextNumber = 1; if (result) { // result === null if no entries match the query nextNumber = result.MerchantInvoiceNumber.InvoiceNumber + 1; } newInvoice.MerchantInvoiceNumber.InvoiceNumber = nextNumber; return Q.nfcall( mainDB.addObject, mainDB.collectionTransaction, newInvoice, undefined, // No options true // Expect errors, so don't kill the DB if we get any ).catch((error) => { debug('- addMonotonicallyNumberedInvoice: insertError:', error); // // This may or may not be an expected error // if (error.name === 'MongoError' && error.code === 11000) { // // This is the duplicate unqiue key error we expect might // happen during a race condition. So try again with 1 less // retry limit return addMonotonicallyNumberedInvoice(newInvoice, maxAttempts - 1); } else { // // Its some other error, so pass it on // return Q.reject(error); } }); }); // // Return the insertPromise so the caller can wait until its done // return insertPromise; } /** * Updates an invoice with new values. Note that this can only be done for * Invoices in the RejectedInvoice state. PendingInvoices should be cancelled * and re-submitted. * * @param {Object} req - Express request object * @param {Object} res - Express response object */ function updateInvoice(req, res) { // // Check that the client is a merchant // if (!req.session.data.isMerchant) { res.status(httpStatus.FORBIDDEN).json({ code: 30710, info: 'Not a merchant' }); return; } const merchantClientID = req.session.data.clientID; const validatedBody = req.swagger.params.body.value; const invoiceId = req.swagger.params.objectId.value; const resubmit = req.swagger.params.resubmit.value; // // Step 1: Validate the customer and merchant account // const findAccountPromise = validateMerchantAccount( merchantClientID, validatedBody.MerchantAccountID ); const findCustomerPromise = validateCustomer(validatedBody.CustomerEmail); // // Step 2: Setup the find query. Limitations: // - Must belong to me as merchant // - Must be the given id // - Must be in the Pending or Rejected state // const findQuery = { MerchantClientID: merchantClientID, _id: mongodb.ObjectID(invoiceId), TransactionStatus: { $in: [ utils.TransactionStatus.PENDING_INVOICE, utils.TransactionStatus.REJECTED_INVOICE ] } }; // // Step 3: Setup the update parameters from what we have been given. // const update = { $set: {}, $inc: { LastVersion: 1 }, $currentDate: { LastUpdate: true } }; const required = ['MerchantAccountID', 'DueDate', 'RequestAmount']; const optional = ['MerchantInvoice', 'MerchantComment']; let idx = 0; for (idx = 0; idx < required.length; ++idx) { update.$set[required[idx]] = validatedBody[required[idx]]; } for (idx = 0; idx < optional.length; ++idx) { const key = optional[idx]; if (validatedBody.hasOwnProperty(key) && validatedBody[key] !== null) { update.$set[key] = validatedBody[key]; } } if (resubmit) { update.$set.TransactionStatus = utils.TransactionStatus.PENDING_INVOICE; } const options = { projection: { _id: 1, CustomerClientName: 1, // Needed for email notification MerchantDisplayName: 1 // Needed for email notification }, upsert: false, returnOriginal: false // Need the updated value, not the old one }; // // Step 4. Check that we validated everything ok, then Run the findAndUpdate // const updatePromise = Q.all([findAccountPromise, findCustomerPromise]) .then((results) => { const customer = results[1]; // // CustomerClientID is special as we have to wait to find it from // the customer data. We also need to update the display name at // the same time // update.$set.CustomerClientID = customer.ClientID; update.$set.CustomerDisplayName = customer.DisplayName; return Q.ninvoke( mainDB.collectionTransaction, 'findOneAndUpdate', findQuery, update, options ); }); // // Step 5. Check everything ran ok, then respond as appropriate // Q.all([findAccountPromise, findCustomerPromise, updatePromise]) .then((results) => { // // Ran the operation successfully, but need to check if it actually // updated anything. // const updateResult = results[2]; if (updateResult.value) { res.status(200).json(); // // Notify the customer that the invoice has been updated. // Note that we don't make the result contingent on the email // sending correctly // return notifyUpdatedInvoice( validatedBody.CustomerEmail, updateResult.value ); } else { return res.status(404).json({ code: 30714, info: 'Invoice not found, or not in Pending or Rejected state' }); } }) .catch((error) => { if ( error && error.hasOwnProperty('name') ) { switch (error.name) { case ERRORS.NO_MERCHANT_ACCOUNT: res.status(httpStatus.CONFLICT).json({ code: 30712, info: 'Invalid Merchant Account' }); break; case ERRORS.NO_CUSTOMER: res.status(httpStatus.CONFLICT).json({ code: 30713, info: 'Customer not found' }); break; case 'MongoError': res.status(httpStatus.BAD_GATEWAY).json({ code: 30711, info: 'Database Unavailable' }); break; default: // // Unknown error // res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ code: -1, info: 'Unexpected error' }); break; } } else { // // Unknown error // res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ code: -1, info: 'Unexpected error' }); } }); } /** * Cancels an invoice (moving the transaction into the Cancelled status * * @param {Object} req - Express request object * @param {Object} res - Express response object */ function cancelInvoice(req, res) { // // Check that the client is a merchant // if (!req.session.data.isMerchant) { res.status(httpStatus.FORBIDDEN).json({ code: 30715, info: 'Not a merchant' }); return; } const merchantClientID = req.session.data.clientID; const invoiceId = req.swagger.params.objectId.value; // // Step 1: Setup the find query. Limitations: // - Must belong to me as merchant // - Must be the given id // - Must not be a pending or rejected invoice // const findQuery = { MerchantClientID: merchantClientID, _id: mongodb.ObjectID(invoiceId), TransactionStatus: { $in: [ utils.TransactionStatus.PENDING_INVOICE, utils.TransactionStatus.REJECTED_INVOICE ] } }; // // Step 2: Setup the update parameters from what we have been given. // const update = { $set: { TransactionStatus: utils.TransactionStatus.CANCELLED_INVOICE }, $inc: { LastVersion: 1 }, $currentDate: { LastUpdate: true } }; const options = { projection: { _id: 1, // Needed for the email notification MerchantDisplayName: 1, MerchantInvoiceNumber: 1, CustomerClientID: 1 }, upsert: false }; // // Step 3. Run the findAndUpdate // Q.ninvoke( mainDB.collectionTransaction, 'findOneAndUpdate', findQuery, update, options ) .then((result) => { // // Ran the operation successfully, but need to check if it actually // updated anything. // if (result.value) { res.status(200).json(); // // Notify the customer that the invoice has been cancelled // return notifyCancelledInvoice( result.value.CustomerClientID, result.value ); } else { return res.status(404).json({ code: 30717, info: 'Invoice not found, or already Cancelled' }); } }) .catch((error) => { if ( error && error.hasOwnProperty('name') ) { switch (error.name) { case 'MongoError': res.status(httpStatus.BAD_GATEWAY).json({ code: 30716, info: 'Database Unavailable' }); break; default: // // Unknown error // res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ code: -1, info: 'Unexpected error' }); break; } } else { // // Unknown error // res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ code: -1, info: 'Unexpected error' }); } }); } /** * Validates that the merchant account exists, and belongs to this user * * @param {string} merchantID - the merchant's client id * @param {string} accountId - the account Id of the merchant account to use * * @returns {Promise} - a promise for finding the account */ function validateMerchantAccount(merchantID, accountId) { const findAccountQuery = { _id: mongodb.ObjectID(accountId), ClientID: merchantID, ReceivingAccount: 1, AccountStatus: { $bitsAllClear: utils.AccountDeleted } }; const findAccountOptions = { fields: { _id: 1, ClientID: 1 }, comment: 'webconsole: add/updateInvoice validate account' }; return Q.nfcall( mainDB.findOneObject, mainDB.collectionAccount, findAccountQuery, findAccountOptions, false // Don't suppress errors ).then((account) => { return account ? account : Q.reject({name: ERRORS.NO_MERCHANT_ACCOUNT}); }); } /** * Validates that the customer email points to a real customer * * @param {string} customerEmail - The customer's email address * * @returns {Promise} - A promise for finding the customer */ function validateCustomer(customerEmail) { const findClientQuery = { ClientName: customerEmail, ClientStatus: {$bitsAllClear: utils.ClientBarredMask} }; const findClientOptions = { fields: { _id: 1, ClientID: 1, ClientName: 1, DisplayName: 1, Selfie: 1 }, comment: 'webconsole: addInvoice validate account' }; return Q.nfcall( mainDB.findOneObject, mainDB.collectionClient, findClientQuery, findClientOptions, false // Don't suppress errors ).then((client) => { return client ? client : Q.reject({name: ERRORS.NO_CUSTOMER}); }); } /** * Notifies the customer that a new invoice has been raised against them. * * @param {string} customerEmail - the customer's email address * @param {Object} invoice - the invoice (for adding info to the email) * * @returns {Promise} - a promise for the result of notifying the customer */ function notifyNewInvoice(customerEmail, invoice) { /** * Render the html for the email */ const htmlEmail = templates.render('invoice-new', { merchant: invoice.MerchantDisplayName, requestAmount: formattingUtils.formatMoney(invoice.RequestAmount) }); return Q.nfcall( mailer.sendEmail, '', // Mode ('Test' to just log, anything else to send) customerEmail, // Destination 'New Invoice', // Subject htmlEmail, 'notifyNewInvoice' ); } /** * Notifies the customer that an existing invoice has been updated. * * @param {string} customerEmail - the customer's email address * @param {Object} invoice - the invoice (for adding info to the email) * * @returns {Promise} - a promise for the result of notifying the customer */ function notifyUpdatedInvoice(customerEmail, invoice) { /** * Render the html for the email */ const htmlEmail = templates.render('invoice-updated', { merchant: invoice.MerchantDisplayName }); return Q.nfcall( mailer.sendEmail, '', // Mode ('Test' to just log, anything else to send) customerEmail, // Destination 'Updated Invoice', // Subject htmlEmail, 'notifyUpdatedInvoice' ); } /** * Notifies the customer that an existing invoice has been cancelled by the merchant. * * @param {string} customerID - the customer's client ID * @param {Object} invoice - the invoice (for adding info to the email) * * @returns {Promise} - a promise for the result of notifying the customer */ function notifyCancelledInvoice(customerID, invoice) { /** * Render the html for the email */ const htmlEmail = templates.render('invoice-cancelled', { merchant: invoice.MerchantDisplayName, number: invoice.MerchantInvoiceNumber.InvoiceNumber }); return Q.nfcall( mailer.sendEmailByID, '', // Mode ('Test' to just log, anything else to send) customerID, // Destination 'Cancelled Invoice', // Subject htmlEmail, 'notifyCancelledInvoice' ); }