From d2c63878ed5611e8803832127766355df64dc771 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Thu, 21 Dec 2023 22:57:17 +0200 Subject: [PATCH] feat: send invoices and reminder notifications to the customers --- .../api/controllers/Sales/SalesInvoices.ts | 21 +++--- .../services/Sales/Invoices/GetSaleInvoice.ts | 3 +- .../Invoices/SaleInvoiceMailFormatter.ts | 43 ++++++++++++ .../services/Sales/Invoices/SaleInvoicePdf.ts | 25 ++++++- .../Sales/Invoices/SaleInvoicesApplication.ts | 14 ++-- .../Sales/Invoices/SendSaleInvoiceMail.ts | 67 +++++++++++++++--- .../Sales/Invoices/SendSaleInvoiceMailJob.ts | 4 +- .../Invoices/SendSaleInvoiceMailReminder.ts | 68 ++++++++++++++++--- .../SendSaleInvoiceMailReminderJob.ts | 4 +- .../src/services/Sales/Invoices/constants.ts | 23 +++++++ 10 files changed, 227 insertions(+), 45 deletions(-) create mode 100644 packages/server/src/services/Sales/Invoices/SaleInvoiceMailFormatter.ts diff --git a/packages/server/src/api/controllers/Sales/SalesInvoices.ts b/packages/server/src/api/controllers/Sales/SalesInvoices.ts index 69be4f4a8..daff92f37 100644 --- a/packages/server/src/api/controllers/Sales/SalesInvoices.ts +++ b/packages/server/src/api/controllers/Sales/SalesInvoices.ts @@ -157,10 +157,11 @@ export default class SaleInvoicesController extends BaseController { '/:id/mail-reminder', [ ...this.specificSaleInvoiceValidation, - body('from').isString().exists(), - body('to').isString().exists(), - body('body').isString().exists(), - body('attach_invoice').exists().isBoolean().toBoolean(), + body('subject').isString().optional(), + body('from').isString().optional(), + body('to').isString().optional(), + body('body').isString().optional(), + body('attach_invoice').optional().isBoolean().toBoolean(), ], this.validationResult, asyncMiddleware(this.sendSaleInvoiceMailReminder.bind(this)), @@ -170,6 +171,7 @@ export default class SaleInvoicesController extends BaseController { '/:id/mail', [ ...this.specificSaleInvoiceValidation, + body('subject').isString().optional(), body('from').isString().optional(), body('to').isString().optional(), body('body').isString().optional(), @@ -677,7 +679,9 @@ export default class SaleInvoicesController extends BaseController { ) { const { tenantId } = req; const { id: invoiceId } = req.params; - const invoiceMailDTO: SendInvoiceMailDTO = this.matchedBodyData(req); + const invoiceMailDTO: SendInvoiceMailDTO = this.matchedBodyData(req, { + includeOptionals: false, + }); try { await this.saleInvoiceApplication.sendSaleInvoiceMail( @@ -692,7 +696,7 @@ export default class SaleInvoicesController extends BaseController { } /** - * + * Retreivers the sale invoice reminder options. * @param {Request} req * @param {Response} res * @param {NextFunction} next @@ -729,8 +733,9 @@ export default class SaleInvoicesController extends BaseController { ) { const { tenantId } = req; const { id: invoiceId } = req.params; - const invoiceMailDTO: SendInvoiceMailDTO = this.matchedBodyData(req); - + const invoiceMailDTO: SendInvoiceMailDTO = this.matchedBodyData(req, { + includeOptionals: false, + }); try { await this.saleInvoiceApplication.sendSaleInvoiceMailReminder( tenantId, diff --git a/packages/server/src/services/Sales/Invoices/GetSaleInvoice.ts b/packages/server/src/services/Sales/Invoices/GetSaleInvoice.ts index f2245afef..b57f86ed9 100644 --- a/packages/server/src/services/Sales/Invoices/GetSaleInvoice.ts +++ b/packages/server/src/services/Sales/Invoices/GetSaleInvoice.ts @@ -24,8 +24,7 @@ export class GetSaleInvoice { */ public async getSaleInvoice( tenantId: number, - saleInvoiceId: number, - authorizedUser: ISystemUser + saleInvoiceId: number ): Promise { const { SaleInvoice } = this.tenancy.models(tenantId); diff --git a/packages/server/src/services/Sales/Invoices/SaleInvoiceMailFormatter.ts b/packages/server/src/services/Sales/Invoices/SaleInvoiceMailFormatter.ts new file mode 100644 index 000000000..a8c85f260 --- /dev/null +++ b/packages/server/src/services/Sales/Invoices/SaleInvoiceMailFormatter.ts @@ -0,0 +1,43 @@ +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { GetSaleInvoice } from './GetSaleInvoice'; +import { Inject, Service } from 'typedi'; +import { Tenant } from '@/system/models'; + +@Service() +export class SaleInvoiceMailFormatter { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private getSaleInvoiceService: GetSaleInvoice; + + /** + * Retrieves the formatted text of the given sale invoice. + * @param {number} tenantId - Tenant id. + * @param {number} invoiceId - Sale invoice id. + * @param {string} text - The given text. + * @returns {Promise} + */ + public formatText = async ( + tenantId: number, + invoiceId: number, + text: string + ): Promise => { + const invoice = await this.getSaleInvoiceService.getSaleInvoice( + tenantId, + invoiceId + ); + const organization = await Tenant.query() + .findById(tenantId) + .withGraphFetched('metadata'); + + return text + .replace('{CompanyName}', organization.metadata.name) + .replace('{CustomerName}', invoice.customer.displayName) + .replace('{InvoiceNumber}', invoice.invoiceNo) + .replace('{InvoiceDueAmount}', invoice.dueAmountFormatted) + .replace('{InvoiceDueDate}', invoice.dueDateFormatted) + .replace('{InvoiceDate}', invoice.invoiceDateFormatted) + .replace('{InvoiceAmount}', invoice.totalFormatted); + }; +} diff --git a/packages/server/src/services/Sales/Invoices/SaleInvoicePdf.ts b/packages/server/src/services/Sales/Invoices/SaleInvoicePdf.ts index 3d17c699c..9cccf94ef 100644 --- a/packages/server/src/services/Sales/Invoices/SaleInvoicePdf.ts +++ b/packages/server/src/services/Sales/Invoices/SaleInvoicePdf.ts @@ -1,7 +1,8 @@ import { Inject, Service } from 'typedi'; import { ChromiumlyTenancy } from '@/services/ChromiumlyTenancy/ChromiumlyTenancy'; import { TemplateInjectable } from '@/services/TemplateInjectable/TemplateInjectable'; -import { ISaleInvoice } from '@/interfaces'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { CommandSaleInvoiceValidators } from './CommandSaleInvoiceValidators'; @Service() export class SaleInvoicePdf { @@ -11,16 +12,34 @@ export class SaleInvoicePdf { @Inject() private templateInjectable: TemplateInjectable; + @Inject() + private validators: CommandSaleInvoiceValidators; + + @Inject() + private tenancy: HasTenancyService; + /** * Retrieve sale invoice pdf content. * @param {number} tenantId - Tenant Id. * @param {ISaleInvoice} saleInvoice - * @returns {Promise} */ - async saleInvoicePdf( + public async saleInvoicePdf( tenantId: number, - saleInvoice: ISaleInvoice + invoiceId: number ): Promise { + const { SaleInvoice } = this.tenancy.models(tenantId); + + const saleInvoice = await SaleInvoice.query() + .findById(invoiceId) + .withGraphFetched('entries.item') + .withGraphFetched('entries.tax') + .withGraphFetched('customer') + .withGraphFetched('taxes.taxRate'); + + // Validates the given sale invoice existance. + this.validators.validateInvoiceExistance(saleInvoice); + const htmlContent = await this.templateInjectable.render( tenantId, 'modules/invoice-regular', diff --git a/packages/server/src/services/Sales/Invoices/SaleInvoicesApplication.ts b/packages/server/src/services/Sales/Invoices/SaleInvoicesApplication.ts index 9ce79b656..38c2c721f 100644 --- a/packages/server/src/services/Sales/Invoices/SaleInvoicesApplication.ts +++ b/packages/server/src/services/Sales/Invoices/SaleInvoicesApplication.ts @@ -249,13 +249,13 @@ export class SaleInvoiceApplication { }; /** - * - * @param {number} tenantId ] - * @param saleInvoice - * @returns + * Retrieves the pdf buffer of the given sale invoice. + * @param {number} tenantId - Tenant id. + * @param {number} saleInvoice + * @returns {Promise} */ - public saleInvoicePdf(tenantId: number, saleInvoice) { - return this.pdfSaleInvoiceService.saleInvoicePdf(tenantId, saleInvoice); + public saleInvoicePdf(tenantId: number, saleInvoiceId: number) { + return this.pdfSaleInvoiceService.saleInvoicePdf(tenantId, saleInvoiceId); } /** @@ -336,7 +336,7 @@ export class SaleInvoiceApplication { saleInvoiceId: number, messageDTO: SendInvoiceMailDTO ) { - return this.sendSaleInvoiceMailService.sendMail( + return this.sendSaleInvoiceMailService.triggerMail( tenantId, saleInvoiceId, messageDTO diff --git a/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMail.ts b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMail.ts index 0fa1aeded..4f3792256 100644 --- a/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMail.ts +++ b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMail.ts @@ -1,9 +1,17 @@ import { Inject, Service } from 'typedi'; -import { ISaleInvoiceNotifyPayload, SendInvoiceMailDTO } from '@/interfaces'; +import * as R from 'ramda'; +import { SendInvoiceMailDTO } from '@/interfaces'; import Mail from '@/lib/Mail'; import HasTenancyService from '@/services/Tenancy/TenancyService'; -import events from '@/subscribers/events'; -import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import { SaleInvoicePdf } from './SaleInvoicePdf'; +import { SaleInvoiceMailFormatter } from './SaleInvoiceMailFormatter'; +import { + DEFAULT_INVOICE_MAIL_CONTENT, + DEFAULT_INVOICE_MAIL_SUBJECT, + ERRORS, +} from './constants'; +import { CommandSaleInvoiceValidators } from './CommandSaleInvoiceValidators'; +import { ServiceError } from '@/exceptions'; @Service() export class SendSaleInvoiceMail { @@ -13,13 +21,22 @@ export class SendSaleInvoiceMail { @Inject('agenda') private agenda: any; + @Inject() + private invoicePdf: SaleInvoicePdf; + + @Inject() + private invoiceFormatter: SaleInvoiceMailFormatter; + + @Inject() + private commandInvoiceValidator: CommandSaleInvoiceValidators; + /** * Sends the invoice mail of the given sale invoice. * @param {number} tenantId * @param {number} saleInvoiceId * @param {SendInvoiceMailDTO} messageDTO */ - public async sendMail( + public async triggerMail( tenantId: number, saleInvoiceId: number, messageDTO: SendInvoiceMailDTO @@ -39,7 +56,7 @@ export class SendSaleInvoiceMail { * @param {SendInvoiceMailDTO} messageDTO * @returns {Promise} */ - public async triggerMail( + public async sendMail( tenantId: number, saleInvoiceId: number, messageDTO: SendInvoiceMailDTO @@ -50,17 +67,45 @@ export class SendSaleInvoiceMail { .findById(saleInvoiceId) .withGraphFetched('customer'); - const toEmail = messageDTO.to || saleInvoice.customer.email; - const subject = messageDTO.subject || saleInvoice.invoiceNo; + this.commandInvoiceValidator.validateInvoiceExistance(saleInvoice); - if (!toEmail) { - return null; + // Parsed message opts with default options. + const parsedMessageOpts = { + attachInvoice: true, + subject: DEFAULT_INVOICE_MAIL_SUBJECT, + body: DEFAULT_INVOICE_MAIL_CONTENT, + to: saleInvoice.customer.email, + ...messageDTO, + }; + // In case there is no email address from the customer or from options, throw an error. + if (!parsedMessageOpts.to) { + throw new ServiceError(ERRORS.NO_INVOICE_CUSTOMER_EMAIL_ADDR); + } + const formatter = R.curry(this.invoiceFormatter.formatText)( + tenantId, + saleInvoiceId + ); + const toEmail = parsedMessageOpts.to; + const subject = await formatter(parsedMessageOpts.subject); + const body = await formatter(parsedMessageOpts.body); + const attachments = []; + + if (parsedMessageOpts.attachInvoice) { + // Retrieves document buffer of the invoice pdf document. + const invoicePdfBuffer = await this.invoicePdf.saleInvoicePdf( + tenantId, + saleInvoiceId + ); + attachments.push({ + filename: 'invoice.pdf', + content: invoicePdfBuffer, + }); } const mail = new Mail() .setSubject(subject) - .setView('mail/UserInvite.html') .setTo(toEmail) - .setData({}); + .setContent(body) + .setAttachments(attachments); await mail.send(); } diff --git a/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailJob.ts b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailJob.ts index 2b73757f3..3c1e49a6c 100644 --- a/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailJob.ts +++ b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailJob.ts @@ -10,7 +10,7 @@ export class SendSaleInvoiceMailJob { constructor(agenda) { agenda.define( 'sale-invoice-mail-send', - { priority: 'high', concurrency: 1 }, + { priority: 'high', concurrency: 2 }, this.handler ); } @@ -23,7 +23,7 @@ export class SendSaleInvoiceMailJob { const sendInvoiceMail = Container.get(SendSaleInvoiceMail); try { - await sendInvoiceMail.triggerMail(tenantId, saleInvoiceId, messageDTO); + await sendInvoiceMail.sendMail(tenantId, saleInvoiceId, messageDTO); done(); } catch (error) { console.log(error); diff --git a/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailReminder.ts b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailReminder.ts index da210cf41..16baf1273 100644 --- a/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailReminder.ts +++ b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailReminder.ts @@ -1,7 +1,18 @@ import { Inject, Service } from 'typedi'; +import * as R from 'ramda'; +import { assign } from 'lodash'; import { SendInvoiceMailDTO } from '@/interfaces'; import Mail from '@/lib/Mail'; import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { CommandSaleInvoiceValidators } from './CommandSaleInvoiceValidators'; +import { SaleInvoiceMailFormatter } from './SaleInvoiceMailFormatter'; +import { + DEFAULT_INVOICE_REMINDER_MAIL_CONTENT, + DEFAULT_INVOICE_REMINDER_MAIL_SUBJECT, + ERRORS, +} from './constants'; +import { SaleInvoicePdf } from './SaleInvoicePdf'; +import { ServiceError } from '@/exceptions'; @Service() export class SendInvoiceMailReminder { @@ -11,6 +22,15 @@ export class SendInvoiceMailReminder { @Inject('agenda') private agenda: any; + @Inject() + private commandInvoiceValidator: CommandSaleInvoiceValidators; + + @Inject() + private invoiceFormatter: SaleInvoiceMailFormatter; + + @Inject() + private invoicePdf: SaleInvoicePdf; + /** * Triggers the reminder mail of the given sale invoice. * @param {number} tenantId @@ -19,12 +39,12 @@ export class SendInvoiceMailReminder { public async triggerMail( tenantId: number, saleInvoiceId: number, - messageDTO: SendInvoiceMailDTO + messageOptions: SendInvoiceMailDTO ) { const payload = { tenantId, saleInvoiceId, - messageDTO, + messageOptions, }; await this.agenda.now('sale-invoice-reminder-mail-send', payload); } @@ -33,13 +53,13 @@ export class SendInvoiceMailReminder { * Triggers the mail invoice. * @param {number} tenantId * @param {number} saleInvoiceId - * @param {SendInvoiceMailDTO} messageDTO + * @param {SendInvoiceMailDTO} messageOptions * @returns {Promise} */ public async sendMail( tenantId: number, saleInvoiceId: number, - messageDTO: SendInvoiceMailDTO + messageOptions: SendInvoiceMailDTO ) { const { SaleInvoice } = this.tenancy.models(tenantId); @@ -47,17 +67,45 @@ export class SendInvoiceMailReminder { .findById(saleInvoiceId) .withGraphFetched('customer'); - const toEmail = messageDTO.to || saleInvoice.customer.email; - const subject = messageDTO.subject || saleInvoice.invoiceNo; + // Validates the invoice existance. + this.commandInvoiceValidator.validateInvoiceExistance(saleInvoice); - if (!toEmail) { - return null; + const parsedMessageOptions = { + attachInvoice: true, + subject: DEFAULT_INVOICE_REMINDER_MAIL_SUBJECT, + body: DEFAULT_INVOICE_REMINDER_MAIL_CONTENT, + to: saleInvoice.customer.email, + ...messageOptions, + }; + // In case there is no email address from the customer or from options, throw an error. + if (!parsedMessageOptions.to) { + throw new ServiceError(ERRORS.NO_INVOICE_CUSTOMER_EMAIL_ADDR); + } + const formatter = R.curry(this.invoiceFormatter.formatText)( + tenantId, + saleInvoiceId + ); + const toEmail = parsedMessageOptions.to; + const subject = await formatter(parsedMessageOptions.subject); + const body = await formatter(parsedMessageOptions.body); + const attachments = []; + + if (parsedMessageOptions.attachInvoice) { + // Retrieves document buffer of the invoice pdf document. + const invoicePdfBuffer = await this.invoicePdf.saleInvoicePdf( + tenantId, + saleInvoiceId + ); + attachments.push({ + filename: 'invoice.pdf', + content: invoicePdfBuffer, + }); } const mail = new Mail() .setSubject(subject) - .setView('mail/UserInvite.html') .setTo(toEmail) - .setData({}); + .setContent(body) + .setAttachments(attachments); await mail.send(); } diff --git a/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailReminderJob.ts b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailReminderJob.ts index 97a622c7d..6570a153f 100644 --- a/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailReminderJob.ts +++ b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailReminderJob.ts @@ -18,11 +18,11 @@ export class SendSaleInvoiceReminderMailJob { * Triggers sending invoice mail. */ private handler = async (job, done: Function) => { - const { tenantId, saleInvoiceId, messageDTO } = job.attrs.data; + const { tenantId, saleInvoiceId, messageOptions } = job.attrs.data; const sendInvoiceMail = Container.get(SendInvoiceMailReminder); try { - await sendInvoiceMail.sendMail(tenantId, saleInvoiceId, messageDTO); + await sendInvoiceMail.sendMail(tenantId, saleInvoiceId, messageOptions); done(); } catch (error) { console.log(error); diff --git a/packages/server/src/services/Sales/Invoices/constants.ts b/packages/server/src/services/Sales/Invoices/constants.ts index 018dec027..79bf67c0a 100644 --- a/packages/server/src/services/Sales/Invoices/constants.ts +++ b/packages/server/src/services/Sales/Invoices/constants.ts @@ -1,3 +1,25 @@ +export const DEFAULT_INVOICE_MAIL_SUBJECT = + 'Invoice {InvoiceNumber} from {CompanyName}'; +export const DEFAULT_INVOICE_MAIL_CONTENT = ` +

Dear {CustomerName}

+

Thank you for your business, You can view or print your invoice from attachements.

+

+Invoice #{InvoiceNumber}
+Due Date : {InvoiceDueDate}
+Amount : {InvoiceAmount}
+

+`; + +export const DEFAULT_INVOICE_REMINDER_MAIL_SUBJECT = + 'Invoice {InvoiceNumber} reminder from {CompanyName}'; +export const DEFAULT_INVOICE_REMINDER_MAIL_CONTENT = ` +

Dear {CustomerName}

+

You might have missed the payment date and the invoice is now overdue by {OverdueDays} days.

+

Invoice #{InvoiceNumber}
+Due Date : {InvoiceDueDate}
+Amount : {InvoiceAmount}

+`; + export const ERRORS = { INVOICE_NUMBER_NOT_UNIQUE: 'INVOICE_NUMBER_NOT_UNIQUE', SALE_INVOICE_NOT_FOUND: 'SALE_INVOICE_NOT_FOUND', @@ -16,6 +38,7 @@ export const ERRORS = { PAYMENT_ACCOUNT_CURRENCY_INVALID: 'PAYMENT_ACCOUNT_CURRENCY_INVALID', SALE_INVOICE_ALREADY_WRITTEN_OFF: 'SALE_INVOICE_ALREADY_WRITTEN_OFF', SALE_INVOICE_NOT_WRITTEN_OFF: 'SALE_INVOICE_NOT_WRITTEN_OFF', + NO_INVOICE_CUSTOMER_EMAIL_ADDR: 'NO_INVOICE_CUSTOMER_EMAIL_ADDR', }; export const DEFAULT_VIEW_COLUMNS = [];