feat: send invoices and reminder notifications to the customers

This commit is contained in:
Ahmed Bouhuolia
2023-12-21 22:57:17 +02:00
parent ef52eaf91a
commit d2c63878ed
10 changed files with 227 additions and 45 deletions

View File

@@ -157,10 +157,11 @@ export default class SaleInvoicesController extends BaseController {
'/:id/mail-reminder', '/:id/mail-reminder',
[ [
...this.specificSaleInvoiceValidation, ...this.specificSaleInvoiceValidation,
body('from').isString().exists(), body('subject').isString().optional(),
body('to').isString().exists(), body('from').isString().optional(),
body('body').isString().exists(), body('to').isString().optional(),
body('attach_invoice').exists().isBoolean().toBoolean(), body('body').isString().optional(),
body('attach_invoice').optional().isBoolean().toBoolean(),
], ],
this.validationResult, this.validationResult,
asyncMiddleware(this.sendSaleInvoiceMailReminder.bind(this)), asyncMiddleware(this.sendSaleInvoiceMailReminder.bind(this)),
@@ -170,6 +171,7 @@ export default class SaleInvoicesController extends BaseController {
'/:id/mail', '/:id/mail',
[ [
...this.specificSaleInvoiceValidation, ...this.specificSaleInvoiceValidation,
body('subject').isString().optional(),
body('from').isString().optional(), body('from').isString().optional(),
body('to').isString().optional(), body('to').isString().optional(),
body('body').isString().optional(), body('body').isString().optional(),
@@ -677,7 +679,9 @@ export default class SaleInvoicesController extends BaseController {
) { ) {
const { tenantId } = req; const { tenantId } = req;
const { id: invoiceId } = req.params; const { id: invoiceId } = req.params;
const invoiceMailDTO: SendInvoiceMailDTO = this.matchedBodyData(req); const invoiceMailDTO: SendInvoiceMailDTO = this.matchedBodyData(req, {
includeOptionals: false,
});
try { try {
await this.saleInvoiceApplication.sendSaleInvoiceMail( await this.saleInvoiceApplication.sendSaleInvoiceMail(
@@ -692,7 +696,7 @@ export default class SaleInvoicesController extends BaseController {
} }
/** /**
* * Retreivers the sale invoice reminder options.
* @param {Request} req * @param {Request} req
* @param {Response} res * @param {Response} res
* @param {NextFunction} next * @param {NextFunction} next
@@ -729,8 +733,9 @@ export default class SaleInvoicesController extends BaseController {
) { ) {
const { tenantId } = req; const { tenantId } = req;
const { id: invoiceId } = req.params; const { id: invoiceId } = req.params;
const invoiceMailDTO: SendInvoiceMailDTO = this.matchedBodyData(req); const invoiceMailDTO: SendInvoiceMailDTO = this.matchedBodyData(req, {
includeOptionals: false,
});
try { try {
await this.saleInvoiceApplication.sendSaleInvoiceMailReminder( await this.saleInvoiceApplication.sendSaleInvoiceMailReminder(
tenantId, tenantId,

View File

@@ -24,8 +24,7 @@ export class GetSaleInvoice {
*/ */
public async getSaleInvoice( public async getSaleInvoice(
tenantId: number, tenantId: number,
saleInvoiceId: number, saleInvoiceId: number
authorizedUser: ISystemUser
): Promise<ISaleInvoice> { ): Promise<ISaleInvoice> {
const { SaleInvoice } = this.tenancy.models(tenantId); const { SaleInvoice } = this.tenancy.models(tenantId);

View File

@@ -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<string>}
*/
public formatText = async (
tenantId: number,
invoiceId: number,
text: string
): Promise<string> => {
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);
};
}

View File

@@ -1,7 +1,8 @@
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import { ChromiumlyTenancy } from '@/services/ChromiumlyTenancy/ChromiumlyTenancy'; import { ChromiumlyTenancy } from '@/services/ChromiumlyTenancy/ChromiumlyTenancy';
import { TemplateInjectable } from '@/services/TemplateInjectable/TemplateInjectable'; import { TemplateInjectable } from '@/services/TemplateInjectable/TemplateInjectable';
import { ISaleInvoice } from '@/interfaces'; import HasTenancyService from '@/services/Tenancy/TenancyService';
import { CommandSaleInvoiceValidators } from './CommandSaleInvoiceValidators';
@Service() @Service()
export class SaleInvoicePdf { export class SaleInvoicePdf {
@@ -11,16 +12,34 @@ export class SaleInvoicePdf {
@Inject() @Inject()
private templateInjectable: TemplateInjectable; private templateInjectable: TemplateInjectable;
@Inject()
private validators: CommandSaleInvoiceValidators;
@Inject()
private tenancy: HasTenancyService;
/** /**
* Retrieve sale invoice pdf content. * Retrieve sale invoice pdf content.
* @param {number} tenantId - Tenant Id. * @param {number} tenantId - Tenant Id.
* @param {ISaleInvoice} saleInvoice - * @param {ISaleInvoice} saleInvoice -
* @returns {Promise<Buffer>} * @returns {Promise<Buffer>}
*/ */
async saleInvoicePdf( public async saleInvoicePdf(
tenantId: number, tenantId: number,
saleInvoice: ISaleInvoice invoiceId: number
): Promise<Buffer> { ): Promise<Buffer> {
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( const htmlContent = await this.templateInjectable.render(
tenantId, tenantId,
'modules/invoice-regular', 'modules/invoice-regular',

View File

@@ -249,13 +249,13 @@ export class SaleInvoiceApplication {
}; };
/** /**
* * Retrieves the pdf buffer of the given sale invoice.
* @param {number} tenantId ] * @param {number} tenantId - Tenant id.
* @param saleInvoice * @param {number} saleInvoice
* @returns * @returns {Promise<Buffer>}
*/ */
public saleInvoicePdf(tenantId: number, saleInvoice) { public saleInvoicePdf(tenantId: number, saleInvoiceId: number) {
return this.pdfSaleInvoiceService.saleInvoicePdf(tenantId, saleInvoice); return this.pdfSaleInvoiceService.saleInvoicePdf(tenantId, saleInvoiceId);
} }
/** /**
@@ -336,7 +336,7 @@ export class SaleInvoiceApplication {
saleInvoiceId: number, saleInvoiceId: number,
messageDTO: SendInvoiceMailDTO messageDTO: SendInvoiceMailDTO
) { ) {
return this.sendSaleInvoiceMailService.sendMail( return this.sendSaleInvoiceMailService.triggerMail(
tenantId, tenantId,
saleInvoiceId, saleInvoiceId,
messageDTO messageDTO

View File

@@ -1,9 +1,17 @@
import { Inject, Service } from 'typedi'; 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 Mail from '@/lib/Mail';
import HasTenancyService from '@/services/Tenancy/TenancyService'; import HasTenancyService from '@/services/Tenancy/TenancyService';
import events from '@/subscribers/events'; import { SaleInvoicePdf } from './SaleInvoicePdf';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; 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() @Service()
export class SendSaleInvoiceMail { export class SendSaleInvoiceMail {
@@ -13,13 +21,22 @@ export class SendSaleInvoiceMail {
@Inject('agenda') @Inject('agenda')
private agenda: any; private agenda: any;
@Inject()
private invoicePdf: SaleInvoicePdf;
@Inject()
private invoiceFormatter: SaleInvoiceMailFormatter;
@Inject()
private commandInvoiceValidator: CommandSaleInvoiceValidators;
/** /**
* Sends the invoice mail of the given sale invoice. * Sends the invoice mail of the given sale invoice.
* @param {number} tenantId * @param {number} tenantId
* @param {number} saleInvoiceId * @param {number} saleInvoiceId
* @param {SendInvoiceMailDTO} messageDTO * @param {SendInvoiceMailDTO} messageDTO
*/ */
public async sendMail( public async triggerMail(
tenantId: number, tenantId: number,
saleInvoiceId: number, saleInvoiceId: number,
messageDTO: SendInvoiceMailDTO messageDTO: SendInvoiceMailDTO
@@ -39,7 +56,7 @@ export class SendSaleInvoiceMail {
* @param {SendInvoiceMailDTO} messageDTO * @param {SendInvoiceMailDTO} messageDTO
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
public async triggerMail( public async sendMail(
tenantId: number, tenantId: number,
saleInvoiceId: number, saleInvoiceId: number,
messageDTO: SendInvoiceMailDTO messageDTO: SendInvoiceMailDTO
@@ -50,17 +67,45 @@ export class SendSaleInvoiceMail {
.findById(saleInvoiceId) .findById(saleInvoiceId)
.withGraphFetched('customer'); .withGraphFetched('customer');
const toEmail = messageDTO.to || saleInvoice.customer.email; this.commandInvoiceValidator.validateInvoiceExistance(saleInvoice);
const subject = messageDTO.subject || saleInvoice.invoiceNo;
if (!toEmail) { // Parsed message opts with default options.
return null; 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() const mail = new Mail()
.setSubject(subject) .setSubject(subject)
.setView('mail/UserInvite.html')
.setTo(toEmail) .setTo(toEmail)
.setData({}); .setContent(body)
.setAttachments(attachments);
await mail.send(); await mail.send();
} }

View File

@@ -10,7 +10,7 @@ export class SendSaleInvoiceMailJob {
constructor(agenda) { constructor(agenda) {
agenda.define( agenda.define(
'sale-invoice-mail-send', 'sale-invoice-mail-send',
{ priority: 'high', concurrency: 1 }, { priority: 'high', concurrency: 2 },
this.handler this.handler
); );
} }
@@ -23,7 +23,7 @@ export class SendSaleInvoiceMailJob {
const sendInvoiceMail = Container.get(SendSaleInvoiceMail); const sendInvoiceMail = Container.get(SendSaleInvoiceMail);
try { try {
await sendInvoiceMail.triggerMail(tenantId, saleInvoiceId, messageDTO); await sendInvoiceMail.sendMail(tenantId, saleInvoiceId, messageDTO);
done(); done();
} catch (error) { } catch (error) {
console.log(error); console.log(error);

View File

@@ -1,7 +1,18 @@
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import * as R from 'ramda';
import { assign } from 'lodash';
import { SendInvoiceMailDTO } from '@/interfaces'; import { SendInvoiceMailDTO } from '@/interfaces';
import Mail from '@/lib/Mail'; import Mail from '@/lib/Mail';
import HasTenancyService from '@/services/Tenancy/TenancyService'; 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() @Service()
export class SendInvoiceMailReminder { export class SendInvoiceMailReminder {
@@ -11,6 +22,15 @@ export class SendInvoiceMailReminder {
@Inject('agenda') @Inject('agenda')
private agenda: any; private agenda: any;
@Inject()
private commandInvoiceValidator: CommandSaleInvoiceValidators;
@Inject()
private invoiceFormatter: SaleInvoiceMailFormatter;
@Inject()
private invoicePdf: SaleInvoicePdf;
/** /**
* Triggers the reminder mail of the given sale invoice. * Triggers the reminder mail of the given sale invoice.
* @param {number} tenantId * @param {number} tenantId
@@ -19,12 +39,12 @@ export class SendInvoiceMailReminder {
public async triggerMail( public async triggerMail(
tenantId: number, tenantId: number,
saleInvoiceId: number, saleInvoiceId: number,
messageDTO: SendInvoiceMailDTO messageOptions: SendInvoiceMailDTO
) { ) {
const payload = { const payload = {
tenantId, tenantId,
saleInvoiceId, saleInvoiceId,
messageDTO, messageOptions,
}; };
await this.agenda.now('sale-invoice-reminder-mail-send', payload); await this.agenda.now('sale-invoice-reminder-mail-send', payload);
} }
@@ -33,13 +53,13 @@ export class SendInvoiceMailReminder {
* Triggers the mail invoice. * Triggers the mail invoice.
* @param {number} tenantId * @param {number} tenantId
* @param {number} saleInvoiceId * @param {number} saleInvoiceId
* @param {SendInvoiceMailDTO} messageDTO * @param {SendInvoiceMailDTO} messageOptions
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
public async sendMail( public async sendMail(
tenantId: number, tenantId: number,
saleInvoiceId: number, saleInvoiceId: number,
messageDTO: SendInvoiceMailDTO messageOptions: SendInvoiceMailDTO
) { ) {
const { SaleInvoice } = this.tenancy.models(tenantId); const { SaleInvoice } = this.tenancy.models(tenantId);
@@ -47,17 +67,45 @@ export class SendInvoiceMailReminder {
.findById(saleInvoiceId) .findById(saleInvoiceId)
.withGraphFetched('customer'); .withGraphFetched('customer');
const toEmail = messageDTO.to || saleInvoice.customer.email; // Validates the invoice existance.
const subject = messageDTO.subject || saleInvoice.invoiceNo; this.commandInvoiceValidator.validateInvoiceExistance(saleInvoice);
if (!toEmail) { const parsedMessageOptions = {
return null; 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() const mail = new Mail()
.setSubject(subject) .setSubject(subject)
.setView('mail/UserInvite.html')
.setTo(toEmail) .setTo(toEmail)
.setData({}); .setContent(body)
.setAttachments(attachments);
await mail.send(); await mail.send();
} }

View File

@@ -18,11 +18,11 @@ export class SendSaleInvoiceReminderMailJob {
* Triggers sending invoice mail. * Triggers sending invoice mail.
*/ */
private handler = async (job, done: Function) => { 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); const sendInvoiceMail = Container.get(SendInvoiceMailReminder);
try { try {
await sendInvoiceMail.sendMail(tenantId, saleInvoiceId, messageDTO); await sendInvoiceMail.sendMail(tenantId, saleInvoiceId, messageOptions);
done(); done();
} catch (error) { } catch (error) {
console.log(error); console.log(error);

View File

@@ -1,3 +1,25 @@
export const DEFAULT_INVOICE_MAIL_SUBJECT =
'Invoice {InvoiceNumber} from {CompanyName}';
export const DEFAULT_INVOICE_MAIL_CONTENT = `
<p>Dear {CustomerName}</p>
<p>Thank you for your business, You can view or print your invoice from attachements.</p>
<p>
Invoice <strong>#{InvoiceNumber}</strong><br />
Due Date : <strong>{InvoiceDueDate}</strong><br />
Amount : <strong>{InvoiceAmount}</strong></br />
</p>
`;
export const DEFAULT_INVOICE_REMINDER_MAIL_SUBJECT =
'Invoice {InvoiceNumber} reminder from {CompanyName}';
export const DEFAULT_INVOICE_REMINDER_MAIL_CONTENT = `
<p>Dear {CustomerName}</p>
<p>You might have missed the payment date and the invoice is now overdue by {OverdueDays} days.</p>
<p>Invoice <strong>#{InvoiceNumber}</strong><br />
Due Date : <strong>{InvoiceDueDate}</strong><br />
Amount : <strong>{InvoiceAmount}</strong></p>
`;
export const ERRORS = { export const ERRORS = {
INVOICE_NUMBER_NOT_UNIQUE: 'INVOICE_NUMBER_NOT_UNIQUE', INVOICE_NUMBER_NOT_UNIQUE: 'INVOICE_NUMBER_NOT_UNIQUE',
SALE_INVOICE_NOT_FOUND: 'SALE_INVOICE_NOT_FOUND', SALE_INVOICE_NOT_FOUND: 'SALE_INVOICE_NOT_FOUND',
@@ -16,6 +38,7 @@ export const ERRORS = {
PAYMENT_ACCOUNT_CURRENCY_INVALID: 'PAYMENT_ACCOUNT_CURRENCY_INVALID', PAYMENT_ACCOUNT_CURRENCY_INVALID: 'PAYMENT_ACCOUNT_CURRENCY_INVALID',
SALE_INVOICE_ALREADY_WRITTEN_OFF: 'SALE_INVOICE_ALREADY_WRITTEN_OFF', SALE_INVOICE_ALREADY_WRITTEN_OFF: 'SALE_INVOICE_ALREADY_WRITTEN_OFF',
SALE_INVOICE_NOT_WRITTEN_OFF: 'SALE_INVOICE_NOT_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 = []; export const DEFAULT_VIEW_COLUMNS = [];