refactor: mail templates

This commit is contained in:
Ahmed Bouhuolia
2025-06-08 16:49:03 +02:00
parent 0a57b6e20e
commit 4366bf478a
44 changed files with 1866 additions and 134 deletions

View File

@@ -17,7 +17,11 @@ import { GetSaleReceiptsService } from './queries/GetSaleReceipts.service';
import { SaleReceipt } from './models/SaleReceipt';
import { IFilterMeta, IPaginationMeta } from '@/interfaces/Model';
import { SaleReceiptMailNotification } from './commands/SaleReceiptMailNotification';
import { CreateSaleReceiptDto, EditSaleReceiptDto } from './dtos/SaleReceipt.dto';
import {
CreateSaleReceiptDto,
EditSaleReceiptDto,
} from './dtos/SaleReceipt.dto';
import { GetSaleReceiptMailStateService } from './queries/GetSaleReceiptMailState.service';
@Injectable()
export class SaleReceiptApplication {
@@ -31,6 +35,7 @@ export class SaleReceiptApplication {
private getSaleReceiptPdfService: SaleReceiptsPdfService,
private getSaleReceiptStateService: GetSaleReceiptState,
private saleReceiptNotifyByMailService: SaleReceiptMailNotification,
private getSaleReceiptMailStateService: GetSaleReceiptMailStateService,
) {}
/**
@@ -172,4 +177,23 @@ export class SaleReceiptApplication {
public getSaleReceiptState(): Promise<ISaleReceiptState> {
return this.getSaleReceiptStateService.getSaleReceiptState();
}
/**
* Retrieves the given sale receipt html.
* @param {number} saleReceiptId
* @returns {Promise<string>}
*/
public getSaleReceiptHtml(saleReceiptId: number) {
return this.getSaleReceiptPdfService.saleReceiptHtml(saleReceiptId);
}
/**
* Retrieves the mail state of the given sale receipt.
* @param {number} saleReceiptId
*/
public getSaleReceiptMailState(
saleReceiptId: number,
): Promise<ISaleReceiptState> {
return this.getSaleReceiptMailStateService.getMailState(saleReceiptId);
}
}

View File

@@ -107,6 +107,11 @@ export class SaleReceiptsController {
'Content-Length': pdfContent.length,
});
res.send(pdfContent);
} else if (acceptHeader.includes(AcceptType.ApplicationTextHtml)) {
const htmlContent =
await this.saleReceiptApplication.getSaleReceiptHtml(id);
return { htmlContent };
} else {
return this.saleReceiptApplication.getSaleReceipt(id);
}

View File

@@ -37,6 +37,8 @@ import { MailModule } from '../Mail/Mail.module';
import { SendSaleReceiptMailQueue } from './constants';
import { SaleReceiptsExportable } from './commands/SaleReceiptsExportable';
import { SaleReceiptsImportable } from './commands/SaleReceiptsImportable';
import { GetSaleReceiptMailStateService } from './queries/GetSaleReceiptMailState.service';
import { GetSaleReceiptMailTemplateService } from './queries/GetSaleReceiptMailTemplate.service';
@Module({
controllers: [SaleReceiptsController],
@@ -79,6 +81,8 @@ import { SaleReceiptsImportable } from './commands/SaleReceiptsImportable';
SendSaleReceiptMailProcess,
SaleReceiptsExportable,
SaleReceiptsImportable,
GetSaleReceiptMailStateService,
GetSaleReceiptMailTemplateService
],
})
export class SaleReceiptsModule {}

View File

@@ -23,8 +23,9 @@ import {
import { SaleReceipt } from '../models/SaleReceipt';
import { MailTransporter } from '@/modules/Mail/MailTransporter.service';
import { Mail } from '@/modules/Mail/Mail';
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { GetSaleReceiptMailTemplateService } from '../queries/GetSaleReceiptMailTemplate.service';
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
@Injectable()
export class SaleReceiptMailNotification {
@@ -42,6 +43,7 @@ export class SaleReceiptMailNotification {
private readonly eventEmitter: EventEmitter2,
private readonly mailTransporter: MailTransporter,
private readonly tenancyContext: TenancyContext,
private readonly getReceiptMailTemplateService: GetSaleReceiptMailTemplateService,
@Inject(SaleReceipt.name)
private readonly saleReceiptModel: TenantModelProxy<typeof SaleReceipt>,
@@ -119,8 +121,12 @@ export class SaleReceiptMailNotification {
receiptId: number,
): Promise<Record<string, string>> => {
const receipt = await this.getSaleReceiptService.getSaleReceipt(receiptId);
const commonArgs = await this.contactMailNotification.getCommonFormatArgs();
return transformReceiptToMailDataArgs(receipt);
return {
...commonArgs,
...transformReceiptToMailDataArgs(receipt),
};
};
/**
@@ -139,7 +145,14 @@ export class SaleReceiptMailNotification {
mailOptions,
formatterArgs,
)) as SaleReceiptMailOpts;
return formattedOptions;
const message = await this.getReceiptMailTemplateService.getMailTemplate(
receiptId,
{
message: formattedOptions.message,
},
);
return { ...formattedOptions, message };
}
/**

View File

@@ -1,18 +1,18 @@
export const DEFAULT_RECEIPT_MAIL_SUBJECT =
'Receipt {Receipt Number} from {Company Name}';
export const DEFAULT_RECEIPT_MAIL_CONTENT = `
<p>Dear {Customer Name}</p>
<p>Thank you for your business, You can view or print your receipt from attachements.</p>
<p>
Receipt <strong>#{Receipt Number}</strong><br />
Amount : <strong>{Receipt Amount}</strong></br />
</p>
export const DEFAULT_RECEIPT_MAIL_CONTENT = `Hi {Customer Name},
<p>
<i>Regards</i><br />
<i>{Company Name}</i>
</p>
`;
Here's receipt # {Receipt Number} for Receipt {Receipt Amount}
The receipt paid on {Receipt Date}, and the total amount paid is {Receipt Amount}.
Please find your sale receipt attached to this email for your reference
If you have any questions, please let us know.
Thanks,
{Company Name}`;
export const SendSaleReceiptMailQueue = 'SendSaleReceiptMailQueue';
export const SendSaleReceiptMailJob = 'SendSaleReceiptMailJob';

View File

@@ -0,0 +1,37 @@
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
import { Inject, Injectable } from '@nestjs/common';
import { SaleReceiptMailNotification } from '../commands/SaleReceiptMailNotification';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { SaleReceipt } from '../models/SaleReceipt';
import { GetSaleReceiptMailStateTransformer } from './GetSaleReceiptMailState.transformer';
@Injectable()
export class GetSaleReceiptMailStateService {
constructor(
private readonly transformer: TransformerInjectable,
private readonly receiptMail: SaleReceiptMailNotification,
@Inject(SaleReceipt.name)
private readonly saleReceiptModel: TenantModelProxy<typeof SaleReceipt>
) {}
/**
* Retrieves the sale receipt mail state of the given sale receipt.
* @param {number} saleReceiptId
*/
public async getMailState(saleReceiptId: number) {
const saleReceipt = await this.saleReceiptModel().query()
.findById(saleReceiptId)
.withGraphFetched('entries.item')
.withGraphFetched('customer')
.throwIfNotFound();
const mailOptions = await this.receiptMail.getMailOptions(saleReceiptId);
return this.transformer.transform(
saleReceipt,
new GetSaleReceiptMailStateTransformer(),
{ mailOptions },
);
}
}

View File

@@ -0,0 +1,216 @@
import { DiscountType } from '@/common/types/Discount';
import { ItemEntryTransformer } from '@/modules/TransactionItemEntry/ItemEntry.transformer';
import { Transformer } from '@/modules/Transformer/Transformer';
export class GetSaleReceiptMailStateTransformer extends Transformer {
/**
* Exclude these attributes from user object.
* @returns {Array}
*/
public excludeAttributes = (): string[] => {
return ['*'];
};
/**
* Included attributes.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return [
'companyName',
'companyLogoUri',
'primaryColor',
'customerName',
'total',
'totalFormatted',
'subtotal',
'subtotalFormatted',
'receiptDate',
'receiptDateFormatted',
'closedAtDate',
'closedAtDateFormatted',
'receiptNumber',
'discountAmount',
'discountAmountFormatted',
'discountPercentage',
'discountPercentageFormatted',
'discountLabel',
'adjustment',
'adjustmentFormatted',
'entries',
];
};
/**
* Retrieves the customer name of the invoice.
* @returns {string}
*/
protected customerName = (receipt) => {
return receipt.customer.displayName;
};
/**
* Retrieves the company name.
* @returns {string}
*/
protected companyName = () => {
return this.context.organization.name;
};
/**
* Retrieves the company logo uri.
* @returns {string | null}
*/
protected companyLogoUri = (receipt) => {
return receipt.pdfTemplate?.companyLogoUri;
};
/**
* Retrieves the primary color.
* @returns {string}
*/
protected primaryColor = (receipt) => {
return receipt.pdfTemplate?.attributes?.primaryColor;
};
/**
*
* @param receipt
* @returns
*/
protected total = (receipt) => {
return receipt.amount;
};
/**
*
* @param receipt
* @returns
*/
protected totalFormatted = (receipt) => {
return this.formatMoney(receipt.amount, {
currencyCode: receipt.currencyCode,
});
};
/**
*
* @param receipt
* @returns
*/
protected subtotal = (receipt) => {
return receipt.amount;
};
/**
*
* @param receipt
* @returns
*/
protected subtotalFormatted = (receipt) => {
return this.formatMoney(receipt.amount, {
currencyCode: receipt.currencyCode,
});
};
/**
*
* @param receipt
* @returns
*/
protected receiptDate = (receipt): string => {
return receipt.receiptDate;
};
/**
*
* @param {ISaleReceipt} invoice
* @returns {string}
*/
protected receiptDateFormatted = (receipt): string => {
return this.formatDate(receipt.receiptDate);
};
/**
* Retrieves the discount label of the estimate.
* @param estimate
* @returns {string}
*/
protected discountLabel(receipt) {
return receipt.discountType === DiscountType.Percentage
? `Discount [${receipt.discountPercentageFormatted}]`
: 'Discount';
}
/**
*
* @param receipt
* @returns
*/
protected closedAtDate = (receipt): string => {
return receipt.closedAt;
};
/**
* Retrieve formatted estimate closed at date.
* @param {ISaleReceipt} invoice
* @returns {String}
*/
protected closedAtDateFormatted = (receipt): string => {
return this.formatDate(receipt.closedAt);
};
/**
*
* @param invoice
* @returns
*/
protected entries = (receipt) => {
return this.item(
receipt.entries,
new GetSaleReceiptEntryMailStateTransformer(),
{
currencyCode: receipt.currencyCode,
},
);
};
/**
* Merges the mail options with the invoice object.
*/
public transform = (object: any) => {
return {
...this.options.mailOptions,
...object,
};
};
}
class GetSaleReceiptEntryMailStateTransformer extends ItemEntryTransformer {
/**
* Exclude these attributes from user object.
* @returns {Array}
*/
public excludeAttributes = (): string[] => {
return ['*'];
};
public name = (entry) => {
return entry.item.name;
};
public includeAttributes = (): string[] => {
return [
'name',
'quantity',
'quantityFormatted',
'rate',
'rateFormatted',
'total',
'totalFormatted',
];
};
}

View File

@@ -0,0 +1,60 @@
import {
ReceiptEmailTemplateProps,
renderReceiptEmailTemplate,
} from '@bigcapital/email-components';
import { Injectable } from '@nestjs/common';
import { GetSaleReceipt } from './GetSaleReceipt.service';
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
import { GetPdfTemplateService } from '@/modules/PdfTemplate/queries/GetPdfTemplate.service';
import { GetSaleReceiptMailTemplateAttributesTransformer } from './GetSaleReceiptMailTemplate.transformer';
@Injectable()
export class GetSaleReceiptMailTemplateService {
constructor(
private readonly getReceiptService: GetSaleReceipt,
private readonly transformer: TransformerInjectable,
private readonly getBrandingTemplate: GetPdfTemplateService,
) {}
/**
* Retrieves the mail template attributes of the given estimate.
* Estimate template attributes are composed of the estimate and branding template attributes.
* @param {number} receiptId - Receipt id.
* @returns {Promise<EstimatePaymentEmailProps>}
*/
public async getMailTemplateAttributes(
receiptId: number,
): Promise<ReceiptEmailTemplateProps> {
const receipt = await this.getReceiptService.getSaleReceipt(receiptId);
const brandingTemplate = await this.getBrandingTemplate.getPdfTemplate(
receipt.pdfTemplateId,
);
const mailTemplateAttributes = await this.transformer.transform(
receipt,
new GetSaleReceiptMailTemplateAttributesTransformer(),
{
receipt,
brandingTemplate,
},
);
return mailTemplateAttributes;
}
/**
* Retrieves the mail template html content.
* @param {number} receiptId
* @param overrideAttributes
* @returns
*/
public async getMailTemplate(
receiptId: number,
overrideAttributes?: Partial<any>,
): Promise<string> {
const attributes = await this.getMailTemplateAttributes(receiptId);
const mergedAttributes = {
...attributes,
...overrideAttributes,
};
return renderReceiptEmailTemplate(mergedAttributes);
}
}

View File

@@ -0,0 +1,201 @@
import { Transformer } from '@/modules/Transformer/Transformer';
export class GetSaleReceiptMailTemplateAttributesTransformer extends Transformer {
public includeAttributes = (): string[] => {
return [
'companyLogoUri',
'companyName',
'primaryColor',
'receiptAmount',
'receiptMessage',
'date',
'dateLabel',
'receiptNumber',
'receiptNumberLabel',
'total',
'totalLabel',
'subtotal',
'subtotalLabel',
'paidAmount',
'paidAmountLabel',
'discount',
'discountLabel',
'adjustment',
'adjustmentLabel',
'items',
];
};
/**
* Exclude all attributes.
* @returns {string[]}
*/
public excludeAttributes = (): string[] => {
return ['*'];
};
/**
* Company logo uri.
* @returns {string}
*/
public companyLogoUri(): string {
return this.options.brandingTemplate?.companyLogoUri;
}
/**
* Company name.
* @returns {string}
*/
public companyName(): string {
return this.context.organization.name;
}
/**
* Primary color
* @returns {string}
*/
public primaryColor(): string {
return this.options?.brandingTemplate?.attributes?.primaryColor;
}
/**
* Receipt number.
* @returns {string}
*/
public receiptNumber(): string {
return this.options.receipt.receiptNumber;
}
/**
* Receipt number label.
* @returns {string}
*/
public receiptNumberLabel(): string {
return 'Receipt # {receiptNumber}';
}
/**
* Date.
* @returns {string}
*/
public date(): string {
return this.options.receipt.date;
}
/**
* Date label.
* @returns {string}
*/
public dateLabel(): string {
return 'Date';
}
/**
* Receipt total.
*/
public total(): string {
return this.options.receipt.totalFormatted;
}
/**
* Receipt total label.
* @returns {string}
*/
public totalLabel(): string {
return 'Total';
}
/**
* Receipt discount.
* @returns {string}
*/
public discount(): string {
return this.options.receipt?.discountAmountFormatted;
}
/**
* Receipt discount label.
* @returns {string}
*/
public discountLabel(): string {
return 'Discount';
}
/**
* Receipt adjustment.
* @returns {string}
*/
public adjustment(): string {
return this.options.receipt?.adjustmentFormatted;
}
/**
* Receipt adjustment label.
* @returns {string}
*/
public adjustmentLabel(): string {
return 'Adjustment';
}
/**
* Receipt subtotal.
* @returns {string}
*/
public subtotal(): string {
return this.options.receipt.subtotalFormatted;
}
/**
* Receipt subtotal label.
* @returns {string}
*/
public subtotalLabel(): string {
return 'Subtotal';
}
/**
* Receipt mail items attributes.
*/
public items(): any[] {
return this.item(
this.options.receipt.entries,
new GetSaleReceiptMailTemplateEntryAttributesTransformer(),
);
}
}
class GetSaleReceiptMailTemplateEntryAttributesTransformer extends Transformer {
public includeAttributes = (): string[] => {
return ['label', 'quantity', 'rate', 'total'];
};
public excludeAttributes = (): string[] => {
return ['*'];
};
public label(entry): string {
return entry?.item?.name;
}
public quantity(entry): string {
return entry?.quantity;
}
public rate(entry): string {
return entry?.rateFormatted;
}
public total(entry): string {
return entry?.totalFormatted;
}
}

View File

@@ -15,6 +15,10 @@ export class SaleReceiptTransformer extends Transformer {
'formattedReceiptDate',
'formattedClosedAtDate',
'formattedCreatedAt',
'subtotalFormatted',
'subtotalLocalFormatted',
'totalFormatted',
'totalLocalFormatted',
'entries',
'attachments',
];
@@ -47,6 +51,40 @@ export class SaleReceiptTransformer extends Transformer {
return this.formatDate(receipt.createdAt);
};
/**
* Retrieves the formatted subtotal.
*/
protected subtotalFormatted = (receipt: SaleReceipt): string => {
return this.formatNumber(receipt.subtotal, { money: false });
};
/**
* Retrieves the estimate formatted subtotal in local currency.
* @param {ISaleReceipt} receipt
* @returns {string}
*/
protected subtotalLocalFormatted = (receipt: SaleReceipt): string => {
return this.formatNumber(receipt.subtotalLocal, {
currencyCode: receipt.currencyCode,
});
};
/**
* Retrieves the receipt formatted total.
* @returns {string}
*/
protected totalFormatted = (receipt: SaleReceipt): string => {
return this.formatNumber(receipt.total, { money: false });
};
/**
* Retrieves the receipt formatted total in local currency.
* @returns {string}
*/
protected totalLocalFormatted = (receipt: SaleReceipt): string => {
return this.formatNumber(receipt.totalLocal, { money: false });
};
/**
* Retrieves the estimate formatted subtotal.
* @param {ISaleReceipt} receipt
@@ -67,6 +105,37 @@ export class SaleReceiptTransformer extends Transformer {
});
};
/**
* Retrieves formatted discount amount.
* @param receipt
* @returns {string}
*/
protected discountAmountFormatted = (receipt: SaleReceipt): string => {
return this.formatNumber(receipt.discountAmount, {
currencyCode: receipt.currencyCode,
});
};
/**
* Retrieves formatted discount percentage.
* @param receipt
* @returns {string}
*/
protected discountPercentageFormatted = (receipt: SaleReceipt): string => {
return receipt.discountPercentage ? `${receipt.discountPercentage}%` : '';
};
/**
* Retrieves formatted adjustment amount.
* @param receipt
* @returns {string}
*/
protected adjustmentFormatted = (receipt: SaleReceipt): string => {
return this.formatMoney(receipt.adjustment, {
currencyCode: receipt.currencyCode,
});
};
/**
* Retrieves the entries of the credit note.
* @param {ISaleReceipt} credit

View File

@@ -4,12 +4,12 @@ import { SaleReceiptBrandingTemplate } from './SaleReceiptBrandingTemplate.servi
import { transformReceiptToBrandingTemplateAttributes } from '../utils';
import { SaleReceipt } from '../models/SaleReceipt';
import { ChromiumlyTenancy } from '@/modules/ChromiumlyTenancy/ChromiumlyTenancy.service';
import { TemplateInjectable } from '@/modules/TemplateInjectable/TemplateInjectable.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { PdfTemplateModel } from '@/modules/PdfTemplate/models/PdfTemplate';
import { ISaleReceiptBrandingTemplateAttributes } from '../types/SaleReceipts.types';
import { events } from '@/common/events/events';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { renderReceiptPaperTemplateHtml } from '@bigcapital/pdf-templates';
@Injectable()
export class SaleReceiptsPdfService {
@@ -24,7 +24,6 @@ export class SaleReceiptsPdfService {
*/
constructor(
private readonly chromiumlyTenancy: ChromiumlyTenancy,
private readonly templateInjectable: TemplateInjectable,
private readonly getSaleReceiptService: GetSaleReceipt,
private readonly saleReceiptBrandingTemplate: SaleReceiptBrandingTemplate,
private readonly eventPublisher: EventEmitter2,
@@ -38,6 +37,16 @@ export class SaleReceiptsPdfService {
>,
) {}
/**
* Retrieves sale receipt html content.
* @param {number} saleReceiptId
*/
public async saleReceiptHtml(saleReceiptId: number) {
const brandingAttributes =
await this.getReceiptBrandingAttributes(saleReceiptId);
return renderReceiptPaperTemplateHtml(brandingAttributes);
}
/**
* Retrieves sale invoice pdf content.
* @param {number} saleReceiptId - Sale receipt identifier.
@@ -47,14 +56,8 @@ export class SaleReceiptsPdfService {
saleReceiptId: number,
): Promise<[Buffer, string]> {
const filename = await this.getSaleReceiptFilename(saleReceiptId);
const htmlContent = await this.saleReceiptHtml(saleReceiptId);
const brandingAttributes =
await this.getReceiptBrandingAttributes(saleReceiptId);
// Converts the receipt template to html content.
const htmlContent = await this.templateInjectable.render(
'modules/receipt-regular',
brandingAttributes,
);
// Renders the html content to pdf document.
const content =
await this.chromiumlyTenancy.convertHtmlContent(htmlContent);

View File

@@ -9,8 +9,8 @@ export const transformReceiptToBrandingTemplateAttributes = (
saleReceipt: ISaleReceipt
): Partial<ISaleReceiptBrandingTemplateAttributes> => {
return {
total: saleReceipt.formattedAmount,
subtotal: saleReceipt.formattedSubtotal,
total: saleReceipt.totalFormatted,
subtotal: saleReceipt.subtotalFormatted,
lines: saleReceipt.entries?.map((entry) => ({
item: entry.item.name,
description: entry.description,
@@ -20,6 +20,11 @@ export const transformReceiptToBrandingTemplateAttributes = (
})),
receiptNumber: saleReceipt.receiptNumber,
receiptDate: saleReceipt.formattedReceiptDate,
adjustment: saleReceipt.adjustmentFormatted,
discount: saleReceipt.discountAmountFormatted,
discountLabel: saleReceipt.discountPercentageFormatted
? `Discount [${saleReceipt.discountPercentageFormatted}]`
: 'Discount',
customerAddress: contactAddressTextFormat(saleReceipt.customer),
};
};