feat: wip send invoice mail payment template

This commit is contained in:
Ahmed Bouhuolia
2024-10-28 18:33:16 +02:00
parent 0111b0e6ff
commit 12189f018d
15 changed files with 396 additions and 114 deletions

View File

@@ -10,6 +10,7 @@ import {
SALE_INVOICE_CREATED,
SALE_INVOICE_DELETED,
SALE_INVOICE_EDITED,
SALE_INVOICE_MAIL_SENT,
SALE_INVOICE_PDF_VIEWED,
SALE_INVOICE_VIEWED,
} from '@/constants/event-tracker';
@@ -43,6 +44,10 @@ export class SaleInvoiceEventsTracker extends EventSubscriber {
events.saleInvoice.onPdfViewed,
this.handleTrackPdfViewedInvoiceEvent
);
bus.subscribe(
events.saleInvoice.onMailSent,
this.handleTrackMailSentInvoiceEvent
);
}
private handleTrackInvoiceCreatedEvent = ({
@@ -90,4 +95,12 @@ export class SaleInvoiceEventsTracker extends EventSubscriber {
properties: {},
});
};
private handleTrackMailSentInvoiceEvent = ({ tenantId }) => {
this.posthog.trackEvent({
distinctId: `tenant-${tenantId}`,
event: SALE_INVOICE_MAIL_SENT,
properties: {},
});
};
}

View File

@@ -4,6 +4,7 @@ import HasTenancyService from '@/services/Tenancy/TenancyService';
import { MailTenancy } from '@/services/MailTenancy/MailTenancy';
import { formatSmsMessage } from '@/utils';
import { Tenant } from '@/system/models';
import { castArray } from 'lodash';
@Service()
export class ContactMailNotification {
@@ -14,76 +15,54 @@ export class ContactMailNotification {
private tenancy: HasTenancyService;
/**
* Parses the default message options.
* @param {number} tenantId -
* @param {number} invoiceId -
* @param {string} subject -
* @param {string} body -
* @returns {Promise<SaleInvoiceMailOptions>}
* Gets the default mail address of the given contact.
* @param {number} tenantId - Tenant id.
* @param {number} invoiceId - Contact id.
* @returns {Promise<Pick<CommonMailOptions, 'to' | 'from'>>}
*/
public async getDefaultMailOptions(
tenantId: number,
contactId: number,
subject: string = '',
body: string = ''
): Promise<CommonMailOptions> {
customerId: number
): Promise<Pick<CommonMailOptions, 'to' | 'from'>> {
const { Customer } = this.tenancy.models(tenantId);
const contact = await Customer.query()
.findById(contactId)
const customer = await Customer.query()
.findById(customerId)
.throwIfNotFound();
const toAddresses = contact.contactAddresses;
const toAddresses = customer.contactAddresses;
const fromAddresses = await this.mailTenancy.senders(tenantId);
const toAddress = toAddresses.find((a) => a.primary);
const fromAddress = fromAddresses.find((a) => a.primary);
const to = toAddress?.mail || '';
const from = fromAddress?.mail || '';
const to = toAddress?.mail ? castArray(toAddress?.mail) : [];
const from = fromAddress?.mail ? castArray(fromAddress?.mail) : [];
return {
subject,
body,
to,
from,
fromAddresses,
toAddresses,
};
return { to, from };
}
/**
* Retrieves the mail options of the given contact.
* @param {number} tenantId - Tenant id.
* @param {number} invoiceId - Invoice id.
* @param {string} defaultSubject - Default subject text.
* @param {string} defaultBody - Default body text.
* @returns {Promise<CommonMailOptions>}
*/
public async getMailOptions(
public async parseMailOptions(
tenantId: number,
contactId: number,
defaultSubject?: string,
defaultBody?: string,
formatterData?: Record<string, any>
mailOptions: CommonMailOptions,
formatterArgs?: Record<string, any>
): Promise<CommonMailOptions> {
const mailOpts = await this.getDefaultMailOptions(
tenantId,
contactId,
defaultSubject,
defaultBody
);
const commonFormatArgs = await this.getCommonFormatArgs(tenantId);
const formatArgs = {
...commonFormatArgs,
...formatterData,
...formatterArgs,
};
const subject = formatSmsMessage(mailOpts.subject, formatArgs);
const body = formatSmsMessage(mailOpts.body, formatArgs);
const subjectFormatted = formatSmsMessage(mailOptions?.subject, formatArgs);
const messageFormatted = formatSmsMessage(mailOptions?.message, formatArgs);
return {
...mailOpts,
subject,
body,
...mailOptions,
subject: subjectFormatted,
message: messageFormatted,
};
}

View File

@@ -1,33 +1,46 @@
import { isEmpty } from 'lodash';
import { castArray, isEmpty } from 'lodash';
import { ServiceError } from '@/exceptions';
import { CommonMailOptions, CommonMailOptionsDTO } from '@/interfaces';
import { CommonMailOptions } from '@/interfaces';
import { ERRORS } from './constants';
/**
* Merges the mail options with incoming options.
* @param {Partial<SaleInvoiceMailOptions>} mailOptions
* @param {Partial<SendInvoiceMailDTO>} overridedOptions
* @throws {ServiceError}
*/
export function parseAndValidateMailOptions(
mailOptions: Partial<CommonMailOptions>,
overridedOptions: Partial<CommonMailOptionsDTO>
) {
export function parseMailOptions(
mailOptions: CommonMailOptions,
overridedOptions: Partial<CommonMailOptions>
): CommonMailOptions {
const mergedMessageOptions = {
...mailOptions,
...overridedOptions,
};
if (isEmpty(mergedMessageOptions.from)) {
const parsedMessageOptions = {
...mergedMessageOptions,
from: mergedMessageOptions?.from
? castArray(mergedMessageOptions?.from)
: [],
to: mergedMessageOptions?.to ? castArray(mergedMessageOptions?.to) : [],
cc: mergedMessageOptions?.cc ? castArray(mergedMessageOptions?.cc) : [],
bcc: mergedMessageOptions?.bcc ? castArray(mergedMessageOptions?.bcc) : [],
};
return parsedMessageOptions;
}
export function validateRequiredMailOptions(
mailOptions: Partial<CommonMailOptions>
) {
if (isEmpty(mailOptions.from)) {
throw new ServiceError(ERRORS.MAIL_FROM_NOT_FOUND);
}
if (isEmpty(mergedMessageOptions.to)) {
if (isEmpty(mailOptions.to)) {
throw new ServiceError(ERRORS.MAIL_TO_NOT_FOUND);
}
if (isEmpty(mergedMessageOptions.subject)) {
if (isEmpty(mailOptions.subject)) {
throw new ServiceError(ERRORS.MAIL_SUBJECT_NOT_FOUND);
}
if (isEmpty(mergedMessageOptions.body)) {
if (isEmpty(mailOptions.message)) {
throw new ServiceError(ERRORS.MAIL_BODY_NOT_FOUND);
}
return mergedMessageOptions;
}

View File

@@ -0,0 +1,66 @@
import { Inject, Service } from 'typedi';
import { GetPdfTemplate } from '@/services/PdfTemplate/GetPdfTemplate';
import { GetSaleInvoice } from './GetSaleInvoice';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
import {
InvoicePaymentEmailProps,
renderInvoicePaymentEmail,
} from '@bigcapital/email-components';
import { GetInvoiceMailTemplateAttributesTransformer } from './GetInvoicePaymentMailAttributesTransformer';
@Service()
export class GetInvoicePaymentMail {
@Inject()
private getSaleInvoiceService: GetSaleInvoice;
@Inject()
private getBrandingTemplate: GetPdfTemplate;
@Inject()
private transformer: TransformerInjectable;
/**
* Retrieves the mail template attributes of the given invoice.
* @param {number} tenantId - Tenant id.
* @param {number} invoiceId - Invoice id.
*/
public async getMailTemplateAttributes(tenantId: number, invoiceId: number) {
const invoice = await this.getSaleInvoiceService.getSaleInvoice(
tenantId,
invoiceId
);
const brandingTemplate = await this.getBrandingTemplate.getPdfTemplate(
tenantId,
invoice.pdfTemplateId
);
const mailTemplateAttributes = await this.transformer.transform(
tenantId,
invoice,
new GetInvoiceMailTemplateAttributesTransformer(),
{
invoice,
brandingTemplate,
}
);
return mailTemplateAttributes;
}
/**
* Retrieves the mail template html content.
* @param {number} tenantId - Tenant id.
* @param {number} invoiceId - Invoice id.
*/
public async getMailTemplate(
tenantId: number,
invoiceId: number,
overrideAttributes?: Partial<InvoicePaymentEmailProps>
): Promise<string> {
const attributes = await this.getMailTemplateAttributes(
tenantId,
invoiceId
);
const mergedAttributes = { ...attributes, ...overrideAttributes };
return renderInvoicePaymentEmail(mergedAttributes);
}
}

View File

@@ -0,0 +1,136 @@
import { Transformer } from '@/lib/Transformer/Transformer';
export class GetInvoiceMailTemplateAttributesTransformer extends Transformer {
/**
* Include these attributes to item entry object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return [
'companyLogoUri',
'companyName',
'invoiceAmount',
'primaryColor',
'invoiceAmount',
'invoiceMessage',
'dueDate',
'dueDateLabel',
'invoiceNumber',
'invoiceNumberLabel',
'total',
'totalLabel',
'dueAmount',
'dueAmountLabel',
'viewInvoiceButtonLabel',
'viewInvoiceButtonUrl',
'items',
];
};
public excludeAttributes = (): string[] => {
return ['*'];
};
public companyLogoUri(): string {
return this.options.brandingTemplate?.attributes?.companyLogoUri;
}
public companyName(): string {
return this.context.organization.name;
}
public invoiceAmount(): string {
return this.options.invoice.totalFormatted;
}
public primaryColor(): string {
return this.options?.brandingTemplate?.attributes?.primaryColor;
}
public invoiceMessage(): string {
return '';
}
public dueDate(): string {
return this.options?.invoice?.dueDateFormatted;
}
public dueDateLabel(): string {
return 'Due {dueDate}';
}
public invoiceNumber(): string {
return this.options?.invoice?.invoiceNo;
}
public invoiceNumberLabel(): string {
return 'Invoice # {invoiceNumber}';
}
public total(): string {
return this.options.invoice?.totalFormatted;
}
public totalLabel(): string {
return 'Total';
}
public dueAmount(): string {
return this.options?.invoice.dueAmountFormatted;
}
public dueAmountLabel(): string {
return 'Due Amount';
}
public viewInvoiceButtonLabel(): string {
return 'View Invoice';
}
public viewInvoiceButtonUrl(): string {
return '';
}
public items(): Array<any> {
return this.item(
this.options.invoice?.entries,
new GetInvoiceMailTemplateItemAttrsTransformer()
);
}
}
class GetInvoiceMailTemplateItemAttrsTransformer extends Transformer {
/**
* Include these attributes to item entry object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return ['quantity', 'label', 'rate'];
};
public excludeAttributes = (): string[] => {
return ['*'];
};
public quantity(entry): string {
return entry?.quantity;
}
public label(entry): string {
console.log(entry);
return entry?.item?.name;
}
public rate(entry): string {
return entry?.rateFormatted;
}
}

View File

@@ -7,6 +7,7 @@ import {
DEFAULT_INVOICE_MAIL_CONTENT,
DEFAULT_INVOICE_MAIL_SUBJECT,
} from './constants';
import { GetInvoicePaymentMail } from './GetInvoicePaymentMail';
@Service()
export class SendSaleInvoiceMailCommon {
@@ -19,6 +20,9 @@ export class SendSaleInvoiceMailCommon {
@Inject()
private contactMailNotification: ContactMailNotification;
@Inject()
private getInvoicePaymentMail: GetInvoicePaymentMail;
/**
* Retrieves the mail options.
* @param {number} tenantId - Tenant id.
@@ -27,11 +31,11 @@ export class SendSaleInvoiceMailCommon {
* @param {string} defaultBody - Subject body.
* @returns {Promise<SaleInvoiceMailOptions>}
*/
public async getMailOption(
public async getInvoiceMailOptions(
tenantId: number,
invoiceId: number,
defaultSubject: string = DEFAULT_INVOICE_MAIL_SUBJECT,
defaultBody: string = DEFAULT_INVOICE_MAIL_CONTENT
defaultMessage: string = DEFAULT_INVOICE_MAIL_CONTENT
): Promise<SaleInvoiceMailOptions> {
const { SaleInvoice } = this.tenancy.models(tenantId);
@@ -39,21 +43,51 @@ export class SendSaleInvoiceMailCommon {
.findById(invoiceId)
.throwIfNotFound();
const formatterData = await this.formatText(tenantId, invoiceId);
const mailOptions = await this.contactMailNotification.getMailOptions(
tenantId,
saleInvoice.customerId,
defaultSubject,
defaultBody,
formatterData
);
const contactMailDefaultOptions =
await this.contactMailNotification.getDefaultMailOptions(
tenantId,
saleInvoice.customerId
);
return {
...mailOptions,
...contactMailDefaultOptions,
attachInvoice: true,
subject: defaultSubject,
message: defaultMessage,
};
}
/**
* Formats the given invoice mail options.
* @param {number} tenantId
* @param {number} invoiceId
* @param {SaleInvoiceMailOptions} mailOptions
* @returns {Promise<SaleInvoiceMailOptions>}
*/
public async formatInvoiceMailOptions(
tenantId: number,
invoiceId: number,
mailOptions: SaleInvoiceMailOptions
): Promise<SaleInvoiceMailOptions> {
const formatterArgs = await this.getInvoiceFormatterArgs(
tenantId,
invoiceId
);
const parsedOptions = await this.contactMailNotification.parseMailOptions(
tenantId,
mailOptions,
formatterArgs
);
const message = await this.getInvoicePaymentMail.getMailTemplate(
tenantId,
invoiceId,
{
// # Invoice message
invoiceMessage: parsedOptions.message,
}
);
return { ...parsedOptions, message };
}
/**
* Retrieves the formatted text of the given sale invoice.
* @param {number} tenantId - Tenant id.
@@ -61,7 +95,7 @@ export class SendSaleInvoiceMailCommon {
* @param {string} text - The given text.
* @returns {Promise<string>}
*/
public formatText = async (
public getInvoiceFormatterArgs = async (
tenantId: number,
invoiceId: number
): Promise<Record<string, string | number>> => {
@@ -69,7 +103,6 @@ export class SendSaleInvoiceMailCommon {
tenantId,
invoiceId
);
return {
CustomerName: invoice.customer.displayName,
InvoiceNumber: invoice.invoiceNo,

View File

@@ -1,15 +1,23 @@
import { Inject, Service } from 'typedi';
import Mail from '@/lib/Mail';
import { ISaleInvoiceMailSend, SendInvoiceMailDTO } from '@/interfaces';
import {
ISaleInvoiceMailSend,
SaleInvoiceMailOptions,
SendInvoiceMailDTO,
} from '@/interfaces';
import { SaleInvoicePdf } from './SaleInvoicePdf';
import { SendSaleInvoiceMailCommon } from './SendInvoiceInvoiceMailCommon';
import {
DEFAULT_INVOICE_MAIL_CONTENT,
DEFAULT_INVOICE_MAIL_SUBJECT,
} from './constants';
import { parseAndValidateMailOptions } from '@/services/MailNotification/utils';
import {
parseMailOptions,
validateRequiredMailOptions,
} from '@/services/MailNotification/utils';
import events from '@/subscribers/events';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import { ParsedNumberSearch } from 'libphonenumber-js';
@Service()
export class SendSaleInvoiceMail {
@@ -57,12 +65,17 @@ export class SendSaleInvoiceMail {
* @param {number} saleInvoiceId
* @returns {Promise<SaleInvoiceMailOptions>}
*/
public async getMailOption(tenantId: number, saleInvoiceId: number) {
return this.invoiceMail.getMailOption(
public async getMailOption(
tenantId: number,
saleInvoiceId: number,
defaultSubject: string = DEFAULT_INVOICE_MAIL_SUBJECT,
defaultMessage: string = DEFAULT_INVOICE_MAIL_CONTENT
): Promise<SaleInvoiceMailOptions> {
return this.invoiceMail.getInvoiceMailOptions(
tenantId,
saleInvoiceId,
DEFAULT_INVOICE_MAIL_SUBJECT,
DEFAULT_INVOICE_MAIL_CONTENT
defaultSubject,
defaultMessage
);
}
@@ -78,44 +91,58 @@ export class SendSaleInvoiceMail {
saleInvoiceId: number,
messageOptions: SendInvoiceMailDTO
) {
const defaultMessageOpts = await this.getMailOption(
const defaultMessageOptions = await this.getMailOption(
tenantId,
saleInvoiceId
);
// Merge message opts with default options and validate the incoming options.
const messageOpts = parseAndValidateMailOptions(
defaultMessageOpts,
// Merges message options with default options and parses the options values.
const parsedMessageOptions = parseMailOptions(
defaultMessageOptions,
messageOptions
);
const mail = new Mail()
.setSubject(messageOpts.subject)
.setTo(messageOpts.to)
.setContent(messageOpts.body);
// Validates the required mail options.
validateRequiredMailOptions(parsedMessageOptions);
if (messageOpts.attachInvoice) {
// Retrieves document buffer of the invoice pdf document.
const invoicePdfBuffer = await this.invoicePdf.saleInvoicePdf(
const formattedMessageOptions =
await this.invoiceMail.formatInvoiceMailOptions(
tenantId,
saleInvoiceId
saleInvoiceId,
parsedMessageOptions
);
const mail = new Mail()
.setSubject(formattedMessageOptions.subject)
.setTo(formattedMessageOptions.to)
.setContent(formattedMessageOptions.message);
// Attach invoice document.
if (formattedMessageOptions.attachInvoice) {
// Retrieves document buffer of the invoice pdf document.
const [invoicePdfBuffer, invoiceFilename] =
await this.invoicePdf.saleInvoicePdf(tenantId, saleInvoiceId);
mail.setAttachments([
{ filename: 'invoice.pdf', content: invoicePdfBuffer },
{ filename: `${invoiceFilename}.pdf`, content: invoicePdfBuffer },
]);
}
// Triggers the event `onSaleInvoiceSend`.
await this.eventPublisher.emitAsync(events.saleInvoice.onMailSend, {
const eventPayload = {
tenantId,
saleInvoiceId,
messageOptions,
} as ISaleInvoiceMailSend);
formattedMessageOptions,
} as ISaleInvoiceMailSend;
// Triggers the event `onSaleInvoiceSend`.
await this.eventPublisher.emitAsync(
events.saleInvoice.onMailSend,
eventPayload
);
await mail.send();
// Triggers the event `onSaleInvoiceSend`.
await this.eventPublisher.emitAsync(events.saleInvoice.onMailSent, {
tenantId,
saleInvoiceId,
messageOptions,
} as ISaleInvoiceMailSend);
await this.eventPublisher.emitAsync(
events.saleInvoice.onMailSent,
eventPayload
);
}
}