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

@@ -119,7 +119,6 @@ export class CreditNoteTransformer extends Transformer {
/**
* Retrieves formatted discount percentage.
* @param credit
* @returns {string}
*/
protected discountPercentageFormatted = (credit): string => {
@@ -128,7 +127,6 @@ export class CreditNoteTransformer extends Transformer {
/**
* Retrieves formatted adjustment amount.
* @param credit
* @returns {string}
*/
protected adjustmentFormatted = (credit): string => {

View File

@@ -0,0 +1,37 @@
import { ArrayMinSize, IsArray, IsNotEmpty, IsObject, IsString } from "class-validator";
import { AddressItem } from "../MailNotification.types";
export class CommonMailOptionsDto {
@IsArray()
@ArrayMinSize(1)
@IsNotEmpty()
from: Array<string>;
@IsString()
@IsNotEmpty()
subject: string;
@IsString()
@IsNotEmpty()
message: string;
@IsArray()
@ArrayMinSize(1)
@IsNotEmpty()
to: Array<string>;
@IsArray()
cc?: Array<string>;
@IsArray()
bcc?: Array<string>;
@IsObject()
formatArgs?: Record<string, any>;
@IsArray()
toOptions: Array<AddressItem>;
@IsArray()
fromOptions: Array<AddressItem>;
}

View File

@@ -1,6 +1,4 @@
import {
IPaymentReceivedCreateDTO,
IPaymentReceivedEditDTO,
IPaymentsReceivedFilter,
PaymentReceiveMailOptsDTO,
} from './types/PaymentReceived.types';
@@ -79,7 +77,9 @@ export class PaymentReceivesApplication {
* @param {IPaymentsReceivedFilter} filterDTO
* @returns
*/
public async getPaymentsReceived(filterDTO: Partial<IPaymentsReceivedFilter>) {
public async getPaymentsReceived(
filterDTO: Partial<IPaymentsReceivedFilter>,
) {
return this.getPaymentsReceivedService.getPaymentReceives(filterDTO);
}
@@ -142,6 +142,17 @@ export class PaymentReceivesApplication {
);
}
/**
* Retrieves html content of the given payment receive.
* @param {number} paymentReceivedId
* @returns {Promise<string>}
*/
public getPaymentReceivedHtml(paymentReceivedId: number) {
return this.getPaymentReceivePdfService.getPaymentReceivedHtml(
paymentReceivedId,
);
}
/**
* Retrieves the create/edit initial state of the payment received.
* @returns {Promise<IPaymentReceivedState>}

View File

@@ -144,7 +144,7 @@ export class PaymentReceivesController {
description:
'The payment received details have been successfully retrieved.',
})
public getPaymentReceive(
public async getPaymentReceive(
@Param('id', ParseIntPipe) paymentReceiveId: number,
@Headers('accept') acceptHeader: string,
) {
@@ -152,6 +152,12 @@ export class PaymentReceivesController {
return this.paymentReceivesApplication.getPaymentReceivePdf(
paymentReceiveId,
);
} else if (acceptHeader.includes(AcceptType.ApplicationTextHtml)) {
const htmlContent =
await this.paymentReceivesApplication.getPaymentReceivedHtml(
paymentReceiveId,
);
return { htmlContent };
} else {
return this.paymentReceivesApplication.getPaymentReceive(
paymentReceiveId,

View File

@@ -4,19 +4,16 @@ export const SEND_PAYMENT_RECEIVED_MAIL_JOB = 'SEND_PAYMENT_RECEIVED_MAIL_JOB';
export const DEFAULT_PAYMENT_MAIL_SUBJECT =
'Payment Received for {Customer Name} from {Company Name}';
export const DEFAULT_PAYMENT_MAIL_CONTENT = `
<p>Dear {Customer Name}</p>
<p>Thank you for your payment. It was a pleasure doing business with you. We look forward to work together again!</p>
<p>
Payment Date : <strong>{Payment Date}</strong><br />
Amount : <strong>{Payment Amount}</strong></br />
</p>
export const DEFAULT_PAYMENT_MAIL_CONTENT = `Dear {Customer Name}
<p>
<i>Regards</i><br />
<i>{Company Name}</i>
</p>
`;
Thank you for your payment. It was a pleasure doing business with you. We look forward to work together again!
Payment Transaction: {Payment Number}
Payment Date : {Payment Date}
Amount : {Payment Amount}
Regards,
{Company Name}`;
export const ERRORS = {
PAYMENT_RECEIVE_NO_EXISTS: 'PAYMENT_RECEIVE_NO_EXISTS',

View File

@@ -76,7 +76,10 @@ export class PaymentReceived extends TenantBaseModel {
const { Customer } = require('../../Customers/models/Customer');
const { Account } = require('../../Accounts/models/Account.model');
const { Branch } = require('../../Branches/models/Branch.model');
const { DocumentModel } = require('../../Attachments/models/Document.model');
const {
DocumentModel,
} = require('../../Attachments/models/Document.model');
const { PdfTemplateModel } = require('../../PdfTemplate/models/PdfTemplate');
return {
customer: {
@@ -154,6 +157,18 @@ export class PaymentReceived extends TenantBaseModel {
query.where('model_ref', 'PaymentReceive');
},
},
/**
* Payment received may belongs to pdf branding template.
*/
pdfTemplate: {
relation: Model.BelongsToOneRelation,
modelClass: PdfTemplateModel,
join: {
from: 'payment_receives.pdfTemplateId',
to: 'pdf_templates.id',
},
},
};
}

View File

@@ -0,0 +1,48 @@
import { Inject, Injectable } from '@nestjs/common';
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
import { GetPaymentReceivedMailStateTransformer } from './GetPaymentReceivedMailState.transformer';
import { SendPaymentReceiveMailNotification } from '../commands/PaymentReceivedMailNotification';
import { PaymentReceived } from '../models/PaymentReceived';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { PaymentReceiveMailOpts } from '../types/PaymentReceived.types';
@Injectable()
export class GetPaymentReceivedMailState {
constructor(
private readonly paymentReceivedMail: SendPaymentReceiveMailNotification,
private readonly transformer: TransformerInjectable,
@Inject(PaymentReceived.name)
private readonly paymentReceivedModel: TenantModelProxy<
typeof PaymentReceived
>,
) {}
/**
* Retrieves the default payment mail options.
* @param {number} paymentReceiveId - Payment receive id.
* @returns {Promise<PaymentReceiveMailOpts>}
*/
public getMailOptions = async (
paymentId: number,
): Promise<PaymentReceiveMailOpts> => {
const paymentReceive = await this.paymentReceivedModel()
.query()
.findById(paymentId)
.withGraphFetched('customer')
.withGraphFetched('entries.invoice')
.withGraphFetched('pdfTemplate')
.throwIfNotFound();
const mailOptions =
await this.paymentReceivedMail.getMailOptions(paymentId);
const transformed = await this.transformer.transform(
paymentReceive,
new GetPaymentReceivedMailStateTransformer(),
{
mailOptions,
},
);
return transformed;
};
}

View File

@@ -0,0 +1,185 @@
import { PaymentReceiveTransfromer } from './PaymentReceivedTransformer';
import { PaymentReceivedEntryTransfromer } from './PaymentReceivedEntryTransformer';
export class GetPaymentReceivedMailStateTransformer extends PaymentReceiveTransfromer {
/**
* Exclude these attributes from user object.
* @returns {Array}
*/
public excludeAttributes = (): string[] => {
return ['*'];
};
/**
* Included attributes.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return [
'paymentDate',
'paymentDateFormatted',
'paymentAmount',
'paymentAmountFormatted',
'total',
'totalFormatted',
'subtotal',
'subtotalFormatted',
'paymentNumber',
'entries',
'companyName',
'companyLogoUri',
'primaryColor',
'customerName',
];
};
/**
* Retrieves the customer name of the payment.
* @returns {string}
*/
protected customerName = (payment) => {
return payment.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 = (payment) => {
return payment.pdfTemplate?.companyLogoUri;
};
/**
* Retrieves the primary color.
* @returns {string}
*/
protected primaryColor = (payment) => {
return payment.pdfTemplate?.attributes?.primaryColor;
};
/**
* Retrieves the formatted payment date.
* @returns {string}
*/
protected paymentDateFormatted = (payment) => {
return this.formatDate(payment.paymentDate);
};
/**
* Retrieves the payment amount.
* @param payment
* @returns {number}
*/
protected total = (payment) => {
return this.formatNumber(payment.amount, {
money: false,
});
};
/**
* Retrieves the formatted payment amount.
* @returns {string}
*/
protected totalFormatted = (payment) => {
return this.formatMoney(payment.amount);
};
/**
* Retrieves the payment amount.
* @param payment
* @returns {number}
*/
protected subtotal = (payment) => {
return this.formatNumber(payment.amount, {
money: false,
});
};
/**
* Retrieves the formatted payment amount.
* @returns {string}
*/
protected subtotalFormatted = (payment) => {
return this.formatMoney(payment.amount);
};
/**
* Retrieves the payment number.
* @param payment
* @returns {string}
*/
protected paymentNumber = (payment) => {
return payment.paymentReceiveNo;
};
/**
* Retrieves the payment entries.
* @param {IPaymentReceived} payment
* @returns {IPaymentReceivedEntry[]}
*/
protected entries = (payment) => {
return this.item(payment.entries, new GetPaymentReceivedEntryMailState());
};
/**
* Merges the mail options with the invoice object.
*/
public transform = (object: any) => {
return {
...this.options.mailOptions,
...object,
};
};
}
export class GetPaymentReceivedEntryMailState extends PaymentReceivedEntryTransfromer {
/**
* Include these attributes to payment receive entry object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return ['paidAmount', 'invoiceNumber'];
};
/**
* Exclude these attributes from user object.
* @returns {Array}
*/
public excludeAttributes = (): string[] => {
return ['*'];
};
/**
* Retrieves the paid amount.
* @param entry
* @returns {string}
*/
public paidAmount = (entry) => {
return this.paymentAmountFormatted(entry);
};
/**
* Retrieves the invoice number.
* @param entry
* @returns {string}
*/
public invoiceNumber = (entry) => {
return entry.invoice.invoiceNo;
};
}

View File

@@ -0,0 +1,62 @@
import {
PaymentReceivedEmailTemplateProps,
renderPaymentReceivedEmailTemplate,
} from '@bigcapital/email-components';
import { Injectable } from '@nestjs/common';
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
import { GetPdfTemplateService } from '@/modules/PdfTemplate/queries/GetPdfTemplate.service';
import { GetPaymentReceivedService } from './GetPaymentReceived.service';
import { GetPaymentReceivedMailTemplateAttrsTransformer } from './GetPaymentReceivedMailTemplateAttrs.transformer';
@Injectable()
export class GetPaymentReceivedMailTemplate {
constructor(
private readonly getPaymentReceivedService: GetPaymentReceivedService,
private readonly getBrandingTemplate: GetPdfTemplateService,
private readonly transformer: TransformerInjectable,
) {}
/**
* Retrieves the mail template attributes of the given payment received.
* @param {number} paymentReceivedId - Payment received id.
* @returns {Promise<PaymentReceivedEmailTemplateProps>}
*/
public async getMailTemplateAttributes(
paymentReceivedId: number,
): Promise<PaymentReceivedEmailTemplateProps> {
const paymentReceived =
await this.getPaymentReceivedService.getPaymentReceive(paymentReceivedId);
const brandingTemplate = await this.getBrandingTemplate.getPdfTemplate(
paymentReceived.pdfTemplateId,
);
const mailTemplateAttributes = await this.transformer.transform(
paymentReceived,
new GetPaymentReceivedMailTemplateAttrsTransformer(),
{
paymentReceived,
brandingTemplate,
},
);
return mailTemplateAttributes;
}
/**
* Retrieves the mail template html content.
* @param {number} tenantId
* @param {number} paymentReceivedId
* @param {Partial<PaymentReceivedEmailTemplateProps>} overrideAttributes
* @returns
*/
public async getMailTemplate(
paymentReceivedId: number,
overrideAttributes?: Partial<PaymentReceivedEmailTemplateProps>,
): Promise<string> {
const mailTemplateAttributes =
await this.getMailTemplateAttributes(paymentReceivedId);
const mergedAttributes = {
...mailTemplateAttributes,
...overrideAttributes,
};
return renderPaymentReceivedEmailTemplate(mergedAttributes);
}
}

View File

@@ -0,0 +1,149 @@
import { Transformer } from "@/modules/Transformer/Transformer";
export class GetPaymentReceivedMailTemplateAttrsTransformer extends Transformer {
/**
* Included attributes.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return [
'companyLogoUri',
'companyName',
'primaryColor',
'total',
'totalLabel',
'subtotal',
'subtotalLabel',
'paymentNumberLabel',
'paymentNumber',
'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;
}
/**
* Total.
* @returns {string}
*/
public total(): string {
return this.options.paymentReceived.formattedAmount;
}
/**
* Total label.
* @returns {string}
*/
public totalLabel(): string {
return 'Total';
}
/**
* Subtotal.
* @returns {string}
*/
public subtotal(): string {
return this.options.paymentReceived.formattedAmount;
}
/**
* Subtotal label.
* @returns {string}
*/
public subtotalLabel(): string {
return 'Subtotal';
}
/**
* Payment number label.
* @returns {string}
*/
public paymentNumberLabel(): string {
return 'Payment # {paymentNumber}';
}
/**
* Payment number.
* @returns {string}
*/
public paymentNumber(): string {
return this.options.paymentReceived.paymentReceiveNumber;
}
/**
* Items.
* @returns
*/
public items() {
return this.item(
this.options.paymentReceived.entries,
new GetPaymentReceivedMailTemplateItemAttrsTransformer()
);
}
}
class GetPaymentReceivedMailTemplateItemAttrsTransformer extends Transformer {
/**
* Included attributes.
* @returns {Array}
*/
public includeAttributes = () => {
return ['label', 'total'];
};
/**
* Excluded attributes.
* @returns {string[]}
*/
public excludeAttributes = () => {
return ['*'];
};
/**
*
* @param entry
* @returns
*/
public label(entry) {
return entry.invoice.invoiceNo;
}
/**
*
* @param entry
* @returns
*/
public total(entry) {
return entry.paymentAmountFormatted;
}
}

View File

@@ -1,22 +1,20 @@
import { Inject, Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { renderPaymentReceivedPaperTemplateHtml } from '@bigcapital/pdf-templates';
import { GetPaymentReceivedService } from './GetPaymentReceived.service';
import { PaymentReceivedBrandingTemplate } from './PaymentReceivedBrandingTemplate.service';
import { transformPaymentReceivedToPdfTemplate } from '../utils';
import { PaymentReceived } from '../models/PaymentReceived';
import { PdfTemplateModel } from '@/modules/PdfTemplate/models/PdfTemplate';
import { ChromiumlyTenancy } from '@/modules/ChromiumlyTenancy/ChromiumlyTenancy.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { TemplateInjectable } from '@/modules/TemplateInjectable/TemplateInjectable.service';
import { PaymentReceivedPdfTemplateAttributes } from '../types/PaymentReceived.types';
import { events } from '@/common/events/events';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { events } from '@/common/events/events';
@Injectable()
export class GetPaymentReceivedPdfService {
constructor(
private chromiumlyTenancy: ChromiumlyTenancy,
private templateInjectable: TemplateInjectable,
private getPaymentService: GetPaymentReceivedService,
private paymentBrandingTemplateService: PaymentReceivedBrandingTemplate,
private eventPublisher: EventEmitter2,
@@ -28,23 +26,31 @@ export class GetPaymentReceivedPdfService {
private pdfTemplateModel: TenantModelProxy<typeof PdfTemplateModel>,
) {}
/**
* Retrieves payment received html content.
* @param {number} paymentReceivedId
* @returns {Promise<string>}
*/
public async getPaymentReceivedHtml(
paymentReceivedId: number,
): Promise<string> {
const brandingAttributes =
await this.getPaymentBrandingAttributes(paymentReceivedId);
return renderPaymentReceivedPaperTemplateHtml(brandingAttributes);
}
/**
* Retrieve sale invoice pdf content.
* @param {number} tenantId -
* @param {IPaymentReceived} paymentReceive -
* @param {number} paymentReceivedId - Payment received id.
* @returns {Promise<Buffer>}
*/
async getPaymentReceivePdf(
paymentReceivedId: number,
): Promise<[Buffer, string]> {
const brandingAttributes =
await this.getPaymentBrandingAttributes(paymentReceivedId);
const htmlContent = await this.templateInjectable.render(
'modules/payment-receive-standard',
brandingAttributes,
);
const htmlContent = await this.getPaymentReceivedHtml(paymentReceivedId);
const filename = await this.getPaymentReceivedFilename(paymentReceivedId);
// Converts the given html content to pdf document.
const content =
await this.chromiumlyTenancy.convertHtmlContent(htmlContent);
@@ -98,7 +104,6 @@ export class GetPaymentReceivedPdfService {
await this.paymentBrandingTemplateService.getPaymentReceivedPdfTemplate(
templateId,
);
return {
...brandingTemplate.attributes,
...transformPaymentReceivedToPdfTemplate(paymentReceived),

View File

@@ -130,23 +130,9 @@ export enum PaymentReceiveAction {
NotifyBySms = 'NotifyBySms',
}
// export type IPaymentReceiveGLCommonEntry = Pick<
// ILedgerEntry,
// | 'debit'
// | 'credit'
// | 'currencyCode'
// | 'exchangeRate'
// | 'transactionId'
// | 'transactionType'
// | 'transactionNumber'
// | 'referenceNumber'
// | 'date'
// | 'userId'
// | 'createdAt'
// | 'branchId'
// >;
export interface PaymentReceiveMailOpts extends CommonMailOptions {}
export interface PaymentReceiveMailOpts extends CommonMailOptions {
attachPdf?: boolean;
}
export interface PaymentReceiveMailOptsDTO extends CommonMailOptionsDTO {}
export interface PaymentReceiveMailPresendEvent {
paymentReceivedId: number;

View File

@@ -18,6 +18,7 @@ import {
CreateSaleEstimateDto,
EditSaleEstimateDto,
} from './dtos/SaleEstimate.dto';
import { GetSaleEstimateMailStateService } from './queries/GetSaleEstimateMailState.service';
@Injectable()
export class SaleEstimatesApplication {
@@ -33,6 +34,7 @@ export class SaleEstimatesApplication {
private readonly sendEstimateMailService: SendSaleEstimateMail,
private readonly getSaleEstimateStateService: GetSaleEstimateState,
private readonly saleEstimatesPdfService: GetSaleEstimatePdf,
private readonly getSaleEstimateMailStateService: GetSaleEstimateMailStateService,
) {}
/**
@@ -172,4 +174,23 @@ export class SaleEstimatesApplication {
public getSaleEstimateState() {
return this.getSaleEstimateStateService.getSaleEstimateState();
}
/**
* Retrieves the sale estimate mail state.
* @param {number} saleEstimateId
* @returns {Promise<SaleEstimateMailOptions>}
*/
public getSaleEstimateMailState(saleEstimateId: number) {
return this.getSaleEstimateMailStateService.getEstimateMailState(
saleEstimateId,
);
}
/**
* Retrieve the HTML content of the given sale estimate.
* @param {number} saleEstimateId
*/
public getSaleEstimateHtml(saleEstimateId: number) {
return this.saleEstimatesPdfService.saleEstimateHtml(saleEstimateId);
}
}

View File

@@ -208,7 +208,7 @@ export class SaleEstimatesController {
}
@Get(':id/mail')
@ApiOperation({ summary: 'Retrieves the sale estimate mail details.' })
@ApiOperation({ summary: 'Retrieves the sale estimate mail state.' })
@ApiParam({
name: 'id',
required: true,
@@ -218,7 +218,9 @@ export class SaleEstimatesController {
public getSaleEstimateMail(
@Param('id', ParseIntPipe) saleEstimateId: number,
) {
return this.saleEstimatesApplication.getSaleEstimateMail(saleEstimateId);
return this.saleEstimatesApplication.getSaleEstimateMailState(
saleEstimateId,
);
}
@Get(':id')
@@ -243,6 +245,11 @@ export class SaleEstimatesController {
'Content-Length': pdfContent.length,
});
res.send(pdfContent);
} else if (acceptHeader.includes(AcceptType.ApplicationTextHtml)) {
const htmlContent =
await this.saleEstimatesApplication.getSaleEstimateHtml(estimateId);
return { htmlContent };
} else {
return this.saleEstimatesApplication.getSaleEstimate(estimateId);
}

View File

@@ -37,6 +37,8 @@ import { PdfTemplatesModule } from '../PdfTemplate/PdfTemplates.module';
import { SendSaleEstimateMailQueue } from './types/SaleEstimates.types';
import { SaleEstimatesExportable } from './SaleEstimatesExportable';
import { SaleEstimatesImportable } from './SaleEstimatesImportable';
import { GetSaleEstimateMailStateService } from './queries/GetSaleEstimateMailState.service';
import { GetSaleEstimateMailTemplateService } from './queries/GetSaleEstimateMailTemplate.service';
@Module({
imports: [
@@ -78,11 +80,15 @@ import { SaleEstimatesImportable } from './SaleEstimatesImportable';
GetSaleEstimatePdf,
SaleEstimatePdfTemplate,
SaleEstimatesExportable,
SaleEstimatesImportable
SaleEstimatesImportable,
GetSaleEstimateMailStateService,
GetSaleEstimateMailTemplateService
],
exports: [
SaleEstimatesExportable,
SaleEstimatesImportable
SaleEstimatesImportable,
GetSaleEstimateMailStateService,
GetSaleEstimateMailTemplateService
]
})
export class SaleEstimatesModule {}

View File

@@ -23,6 +23,7 @@ import { SaleEstimateMailOptions } from '../types/SaleEstimates.types';
import { Mail } from '@/modules/Mail/Mail';
import { MailTransporter } from '@/modules/Mail/MailTransporter.service';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { GetSaleEstimateMailTemplateService } from '../queries/GetSaleEstimateMailTemplate.service';
@Injectable()
export class SendSaleEstimateMail {
@@ -38,6 +39,7 @@ export class SendSaleEstimateMail {
private readonly estimatePdf: GetSaleEstimatePdf,
private readonly getSaleEstimateService: GetSaleEstimate,
private readonly contactMailNotification: ContactMailNotification,
private readonly getEstimateMailTemplate: GetSaleEstimateMailTemplateService,
private readonly eventPublisher: EventEmitter2,
private readonly mailTransporter: MailTransporter,
@@ -78,7 +80,12 @@ export class SendSaleEstimateMail {
*/
public formatterArgs = async (estimateId: number) => {
const estimate = await this.getSaleEstimateService.getEstimate(estimateId);
return transformEstimateToMailDataArgs(estimate);
const commonArgs = await this.contactMailNotification.getCommonFormatArgs();
return {
...commonArgs,
...transformEstimateToMailDataArgs(estimate),
};
};
/**
@@ -129,9 +136,35 @@ export class SendSaleEstimateMail {
mailOptions,
formatterArgs,
);
return { ...formattedOptions };
// Retrieves the estimate mail template.
const message = await this.getEstimateMailTemplate.getMailTemplate(
saleEstimateId,
{
message: formattedOptions.message,
preview: formattedOptions.message,
},
);
return { ...formattedOptions, message };
};
/**
* Retrieves the formatted mail options.
* @param {number} saleEstimateId
* @param {SaleEstimateMailOptionsDTO} messageOptions
* @returns {Promise<SaleEstimateMailOptions>}
*/
public async getFormattedMailOptions(
saleEstimateId: number,
messageOptions: SaleEstimateMailOptionsDTO,
): Promise<SaleEstimateMailOptions> {
const defaultMessageOptions = await this.getMailOptions(saleEstimateId);
const parsedMessageOptions = mergeAndValidateMailOptions(
defaultMessageOptions,
messageOptions,
);
return this.formatMailOptions(saleEstimateId, parsedMessageOptions);
}
/**
* Sends the mail notification of the given sale estimate.
* @param {number} saleEstimateId - Sale estimate id.
@@ -142,16 +175,9 @@ export class SendSaleEstimateMail {
saleEstimateId: number,
messageOptions: SaleEstimateMailOptionsDTO,
): Promise<void> {
const localMessageOpts = await this.getMailOptions(saleEstimateId);
// Overrides and validates the given mail options.
const parsedMessageOptions = mergeAndValidateMailOptions(
localMessageOpts,
messageOptions,
) as SaleEstimateMailOptions;
const formattedOptions = await this.formatMailOptions(
const formattedOptions = await this.getFormattedMailOptions(
saleEstimateId,
parsedMessageOptions,
messageOptions,
);
const mail = new Mail()
.setSubject(formattedOptions.subject)
@@ -173,7 +199,6 @@ export class SendSaleEstimateMail {
},
]);
}
const eventPayload = {
saleEstimateId,
messageOptions,

View File

@@ -1,18 +1,17 @@
export const DEFAULT_ESTIMATE_REMINDER_MAIL_SUBJECT =
'Estimate {Estimate Number} is awaiting your approval';
export const DEFAULT_ESTIMATE_REMINDER_MAIL_CONTENT = `<p>Dear {Customer Name}</p>
<p>Thank you for your business, You can view or print your estimate from attachements.</p>
<p>
Estimate <strong>#{Estimate Number}</strong><br />
Expiration Date : <strong>{Estimate Expiration Date}</strong><br />
Amount : <strong>{Estimate Amount}</strong></br />
</p>
export const DEFAULT_ESTIMATE_REMINDER_MAIL_CONTENT = `Hi {Customer Name},
<p>
<i>Regards</i><br />
<i>{Company Name}</i>
</p>
`;
Here's estimate # {Estimate Number} for {Estimate Amount}
This estimate is valid until {Estimate Expiration Date}, and were happy to discuss any adjustments you or questions may have.
Please find your estimate attached to this email for your reference.
If you have any questions, please let us know.
Thanks,
{Company Name}`;
export const ERRORS = {
SALE_ESTIMATE_NOT_FOUND: 'SALE_ESTIMATE_NOT_FOUND',

View File

@@ -1,6 +1,5 @@
import * as moment from 'moment';
import { Model } from 'objection';
import { Injectable } from '@nestjs/common';
import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel';
import { ExportableModel } from '@/modules/Export/decorators/ExportableModel.decorator';
import { ImportableModel } from '@/modules/Import/decorators/Import.decorator';
@@ -307,12 +306,6 @@ export class SaleEstimate extends TenantBaseModel {
},
};
}
/**
* Model settings.
*/
// static get meta() {
// return SaleEstimateSettings;
// }
/**
* Retrieve the default custom views, roles and columns.

View File

@@ -0,0 +1,167 @@
import { Transformer } from '@/modules/Transformer/Transformer';
export class GetEstimateMailTemplateAttributesTransformer extends Transformer {
public includeAttributes = (): string[] => {
return [
'companyLogoUri',
'companyName',
'estimateAmount',
'primaryColor',
'estimateAmount',
'estimateMessage',
'dueDate',
'dueDateLabel',
'estimateNumber',
'estimateNumberLabel',
'total',
'totalLabel',
'subtotal',
'subtotalLabel',
'dueAmount',
'dueAmountLabel',
'viewEstimateButtonLabel',
'viewEstimateButtonUrl',
'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;
}
/**
* Estimate number.
* @returns {string}
*/
public estimateNumber(): string {
return this.options.estimate.estimateNumber;
}
/**
* Estimate number label.
* @returns {string}
*/
public estimateNumberLabel(): string {
return 'Estimate No: {estimateNumber}';
}
/**
* Expiration date.
* @returns {string}
*/
public expirationDate(): string {
return this.options.estimate.formattedExpirationDate;
}
/**
* Expiration date label.
* @returns {string}
*/
public expirationDateLabel(): string {
return 'Expiration Date: {expirationDate}';
}
/**
* Estimate total.
*/
public total(): string {
return this.options.estimate.formattedAmount;
}
/**
* Estimate total label.
* @returns {string}
*/
public totalLabel(): string {
return 'Total';
}
/**
* Estimate subtotal.
*/
public subtotal(): string {
return this.options.estimate.formattedAmount;
}
/**
* Estimate subtotal label.
* @returns {string}
*/
public subtotalLabel(): string {
return 'Subtotal';
}
/**
* Estimate mail items attributes.
*/
public items(): any[] {
return this.item(
this.options.estimate.entries,
new GetEstimateMailTemplateEntryAttributesTransformer(),
);
}
}
class GetEstimateMailTemplateEntryAttributesTransformer 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

@@ -0,0 +1,46 @@
import { Inject, Injectable } from '@nestjs/common';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { SaleEstimate } from '../models/SaleEstimate';
import { SendSaleEstimateMail } from '../commands/SendSaleEstimateMail';
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
import { GetSaleEstimateMailStateTransformer } from './GetSaleEstimateMailState.transformer';
@Injectable()
export class GetSaleEstimateMailStateService {
constructor(
private readonly estimateMail: SendSaleEstimateMail,
private readonly transformer: TransformerInjectable,
@Inject(SaleEstimate.name)
private readonly saleEstimateModel: TenantModelProxy<typeof SaleEstimate>
) {}
/**
* Retrieves the estimate mail state of the given sale estimate.
* Estimate mail state includes the mail options, branding attributes and the estimate details.
* @param {number} saleEstimateId
* @returns {Promise<SaleEstimateMailState>}
*/
async getEstimateMailState(
saleEstimateId: number
) {
const saleEstimate = await this.saleEstimateModel().query()
.findById(saleEstimateId)
.withGraphFetched('customer')
.withGraphFetched('entries.item')
.withGraphFetched('pdfTemplate')
.throwIfNotFound();
const mailOptions = await this.estimateMail.getMailOptions(
saleEstimateId
);
const transformed = await this.transformer.transform(
saleEstimate,
new GetSaleEstimateMailStateTransformer(),
{
mailOptions,
}
);
return transformed;
}
}

View File

@@ -0,0 +1,171 @@
import { ItemEntryTransformer } from '@/modules/TransactionItemEntry/ItemEntry.transformer';
import { SaleEstimateTransfromer } from './SaleEstimate.transformer';
export class GetSaleEstimateMailStateTransformer extends SaleEstimateTransfromer {
public excludeAttributes = (): string[] => {
return ['*'];
};
public includeAttributes = (): string[] => {
return [
'estimateDate',
'estimateDateFormatted',
'expirationDate',
'expirationDateFormatted',
'total',
'totalFormatted',
'subtotal',
'subtotalFormatted',
'estimateNumber',
'entries',
'companyName',
'companyLogoUri',
'primaryColor',
'customerName',
];
};
/**
* Retrieves the customer name of the invoice.
* @returns {string}
*/
protected customerName = (invoice) => {
return invoice.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 = (invoice) => {
return invoice.pdfTemplate?.companyLogoUri || null;
};
/**
* Retrieves the primary color.
* @returns {string}
*/
protected primaryColor = (invoice) => {
return invoice.pdfTemplate?.attributes?.primaryColor || null;
};
/**
* Retrieves the estimate number.
*/
protected estimateDateFormatted = (estimate) => {
return this.formattedEstimateDate(estimate);
};
/**
* Retrieves the expiration date of the estimate.
* @param estimate
* @returns {string}
*/
protected expirationDateFormatted = (estimate) => {
return this.formattedExpirationDate(estimate);
};
/**
* Retrieves the total amount of the estimate.
* @param estimate
* @returns
*/
protected total(estimate) {
return estimate.amount;
}
/**
* Retrieves the subtotal amount of the estimate.
* @param estimate
* @returns {number}
*/
protected subtotal(estimate) {
return estimate.amount;
}
/**
* Retrieves the formatted total of the estimate.
* @param estimate
* @returns {string}
*/
protected totalFormatted(estimate) {
return this.formatMoney(estimate.amount, {
currencyCode: estimate.currencyCode,
money: true,
});
}
/**
* Retrieves the formatted subtotal of the estimate.
* @param estimate
* @returns {string}
*/
protected subtotalFormatted = (estimate) => {
return this.formatNumber(estimate.amount, { money: false });
};
/**
* Retrieves the estimate entries.
* @param invoice
* @returns {Array}
*/
protected entries = (invoice) => {
return this.item(
invoice.entries,
new GetSaleEstimateMailStateEntryTransformer(),
{
currencyCode: invoice.currencyCode,
},
);
};
/**
* Merges the mail options with the invoice object.
*/
public transform = (object: any) => {
return {
...this.options.mailOptions,
...object,
};
};
}
class GetSaleEstimateMailStateEntryTransformer extends ItemEntryTransformer {
public excludeAttributes = (): string[] => {
return ['*'];
};
/**
* Item name.
* @param entry
* @returns
*/
public name = (entry) => {
return entry.item.name;
};
public includeAttributes = (): string[] => {
return [
'name',
'quantity',
'unitPrice',
'unitPriceFormatted',
'total',
'totalFormatted',
];
};
}

View File

@@ -0,0 +1,61 @@
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
import {
renderEstimateEmailTemplate,
EstimatePaymentEmailProps,
} from '@bigcapital/email-components';
import { Injectable } from '@nestjs/common';
import { GetSaleEstimate } from './GetSaleEstimate.service';
import { GetPdfTemplateService } from '@/modules/PdfTemplate/queries/GetPdfTemplate.service';
import { GetEstimateMailTemplateAttributesTransformer } from './GetEstimateMailTemplateAttributes.transformer';
@Injectable()
export class GetSaleEstimateMailTemplateService {
constructor(
private readonly getEstimateService: GetSaleEstimate,
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} estimateId - Estimate id.
* @returns {Promise<EstimatePaymentEmailProps>}
*/
public async getMailTemplateAttributes(
estimateId: number,
): Promise<EstimatePaymentEmailProps> {
const estimate = await this.getEstimateService.getEstimate(estimateId);
const brandingTemplate = await this.getBrandingTemplate.getPdfTemplate(
estimate.pdfTemplateId,
);
const mailTemplateAttributes = await this.transformer.transform(
estimate,
new GetEstimateMailTemplateAttributesTransformer(),
{
estimate,
brandingTemplate,
},
);
return mailTemplateAttributes;
}
/**
* Rertieves the mail template html content.
* @param {number} tenantId
* @param {number} estimateId
* @param overrideAttributes
* @returns
*/
public async getMailTemplate(
estimateId: number,
overrideAttributes?: Partial<any>,
): Promise<string> {
const attributes = await this.getMailTemplateAttributes(estimateId);
const mergedAttributes = {
...attributes,
...overrideAttributes,
};
return renderEstimateEmailTemplate(mergedAttributes);
}
}

View File

@@ -4,18 +4,17 @@ import { GetSaleEstimate } from './GetSaleEstimate.service';
import { transformEstimateToPdfTemplate } from '../utils';
import { EstimatePdfBrandingAttributes } from '../constants';
import { SaleEstimatePdfTemplate } from '@/modules/SaleInvoices/queries/SaleEstimatePdfTemplate.service';
import { TemplateInjectable } from '@/modules/TemplateInjectable/TemplateInjectable.service';
import { ChromiumlyTenancy } from '@/modules/ChromiumlyTenancy/ChromiumlyTenancy.service';
import { PdfTemplateModel } from '@/modules/PdfTemplate/models/PdfTemplate';
import { events } from '@/common/events/events';
import { SaleEstimate } from '../models/SaleEstimate';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { renderEstimatePaperTemplateHtml } from '@bigcapital/pdf-templates';
@Injectable()
export class GetSaleEstimatePdf {
constructor(
private readonly chromiumlyTenancy: ChromiumlyTenancy,
private readonly templateInjectable: TemplateInjectable,
private readonly getSaleEstimate: GetSaleEstimate,
private readonly estimatePdfTemplate: SaleEstimatePdfTemplate,
private readonly eventPublisher: EventEmitter2,
@@ -29,22 +28,29 @@ export class GetSaleEstimatePdf {
private readonly saleEstimateModel: TenantModelProxy<typeof SaleEstimate>,
) {}
/**
* Retrieve sale estimate html content.
* @param {number} invoiceId -
*/
public async saleEstimateHtml(estimateId: number): Promise<string> {
const brandingAttributes =
await this.getEstimateBrandingAttributes(estimateId);
return renderEstimatePaperTemplateHtml({ ...brandingAttributes });
}
/**
* Retrieve sale invoice pdf content.
* @param {number} tenantId -
* @param {ISaleInvoice} saleInvoice -
* @param {ISaleInvoice} saleInvoice - Sale estimate id.
*/
public async getSaleEstimatePdf(
saleEstimateId: number,
): Promise<[Buffer, string]> {
const filename = await this.getSaleEstimateFilename(saleEstimateId);
const brandingAttributes =
await this.getEstimateBrandingAttributes(saleEstimateId);
const htmlContent = await this.templateInjectable.render(
'modules/estimate-regular',
brandingAttributes,
);
// Retrieves the sale estimate html.
const htmlContent = await this.saleEstimateHtml(saleEstimateId);
const content =
await this.chromiumlyTenancy.convertHtmlContent(htmlContent);
const eventPayload = { saleEstimateId };
@@ -72,7 +78,6 @@ export class GetSaleEstimatePdf {
/**
* Retrieves the given estimate branding attributes.
* @param {number} tenantId - Tenant id.
* @param {number} estimateId - Estimate id.
* @returns {Promise<EstimatePdfBrandingAttributes>}
*/

View File

@@ -171,6 +171,9 @@ export class SaleInvoicesController {
'Content-Length': pdfContent.length,
});
res.send(pdfContent);
} else if (acceptHeader.includes(AcceptType.ApplicationTextHtml)) {
const htmlContent = await this.saleInvoiceApplication.saleInvoiceHtml(id);
return { htmlContent };
} else {
return this.saleInvoiceApplication.getSaleInvoice(id);
}
@@ -270,7 +273,7 @@ export class SaleInvoicesController {
return this.saleInvoiceApplication.saleInvoiceHtml(id);
}
@Get(':id/mail-state')
@Get(':id/mail')
@ApiOperation({ summary: 'Retrieves the sale invoice mail state.' })
@ApiResponse({
status: 200,

View File

@@ -39,7 +39,7 @@ export class GetInvoicePaymentMail {
/**
* Retrieves the mail template html content.
* @param {number} invoiceId - Invoice id.
* @param {number} invoiceId - Sale invoice id.
*/
public async getMailTemplate(
invoiceId: number,

View File

@@ -1,4 +1,4 @@
import { Transformer } from "@/modules/Transformer/Transformer";
import { Transformer } from '@/modules/Transformer/Transformer';
export class GetInvoicePaymentMailAttributesTransformer extends Transformer {
/**
@@ -26,6 +26,15 @@ export class GetInvoicePaymentMailAttributesTransformer extends Transformer {
'total',
'totalLabel',
'subtotal',
'subtotalLabel',
'discount',
'discountLabel',
'adjustment',
'adjustmentLabel',
'dueAmount',
'dueAmountLabel',
@@ -76,6 +85,30 @@ export class GetInvoicePaymentMailAttributesTransformer extends Transformer {
return 'Invoice # {invoiceNumber}';
}
public subtotal(): string {
return this.options.invoice?.subtotalFormatted;
}
public subtotalLabel(): string {
return 'Subtotal';
}
public discount(): string {
return this.options.invoice?.discountAmountFormatted;
}
public discountLabel(): string {
return 'Discount';
}
public adjustment(): string {
return this.options.invoice?.adjustmentFormatted;
}
public adjustmentLabel(): string {
return 'Adjustment';
}
public total(): string {
return this.options.invoice?.totalFormatted;
}
@@ -103,7 +136,7 @@ export class GetInvoicePaymentMailAttributesTransformer extends Transformer {
public items(): Array<any> {
return this.item(
this.options.invoice?.entries,
new GetInvoiceMailTemplateItemAttrsTransformer()
new GetInvoiceMailTemplateItemAttrsTransformer(),
);
}
}

View File

@@ -31,6 +31,15 @@ export class GetSaleInvoiceMailStateTransformer extends SaleInvoiceTransformer {
'subtotal',
'subtotalFormatted',
'discountAmount',
'discountAmountFormatted',
'discountPercentage',
'discountPercentageFormatted',
'discountLabel',
'adjustment',
'adjustmentFormatted',
'invoiceNo',
'entries',
@@ -76,6 +85,17 @@ export class GetSaleInvoiceMailStateTransformer extends SaleInvoiceTransformer {
return invoice.pdfTemplate?.attributes?.primaryColor;
};
/**
* Retrieves the discount label of the estimate.
* @param estimate
* @returns {string}
*/
protected discountLabel(invoice) {
return invoice.discountType === 'percentage'
? `Discount [${invoice.discountPercentageFormatted}]`
: 'Discount';
}
/**
*
* @param invoice
@@ -87,7 +107,7 @@ export class GetSaleInvoiceMailStateTransformer extends SaleInvoiceTransformer {
new GetSaleInvoiceMailStateEntryTransformer(),
{
currencyCode: invoice.currencyCode,
}
},
);
};

View File

@@ -3,6 +3,7 @@ import { SaleInvoice } from '../models/SaleInvoice';
import { ItemEntryTransformer } from '../../TransactionItemEntry/ItemEntry.transformer';
import { AttachmentTransformer } from '../../Attachments/Attachment.transformer';
import { SaleInvoiceTaxEntryTransformer } from './SaleInvoiceTaxEntry.transformer';
import { DiscountType } from '@/common/types/Discount';
export class SaleInvoiceTransformer extends Transformer {
/**
@@ -25,6 +26,9 @@ export class SaleInvoiceTransformer extends Transformer {
'taxAmountWithheldLocalFormatted',
'totalFormatted',
'totalLocalFormatted',
'discountAmountFormatted',
'discountPercentageFormatted',
'adjustmentFormatted',
'taxes',
'entries',
'attachments',
@@ -180,6 +184,39 @@ export class SaleInvoiceTransformer extends Transformer {
});
};
/**
* Retrieves formatted discount amount.
* @param invoice
* @returns {string}
*/
protected discountAmountFormatted = (invoice: SaleInvoice): string => {
return this.formatNumber(invoice.discountAmount, {
currencyCode: invoice.currencyCode,
});
};
/**
* Retrieves formatted discount percentage.
* @param invoice
* @returns {string}
*/
protected discountPercentageFormatted = (invoice: SaleInvoice): string => {
return invoice.discountType === DiscountType.Percentage
? `${invoice.discount}%`
: '';
};
/**
* Retrieves formatted adjustment amount.
* @param invoice
* @returns {string}
*/
protected adjustmentFormatted = (invoice: SaleInvoice): string => {
return this.formatMoney(invoice.adjustment, {
currencyCode: invoice.currencyCode,
})
}
/**
* Retrieve the taxes lines of sale invoice.
* @param {ISaleInvoice} invoice

View File

@@ -47,7 +47,6 @@ export class SaleInvoicePdf {
*/
public async getSaleInvoicePdf(invoiceId: number): Promise<[Buffer, string]> {
const filename = await this.getInvoicePdfFilename(invoiceId);
const htmlContent = await this.getSaleInvoiceHtml(invoiceId);
// Converts the given html content to pdf document.

View File

@@ -44,7 +44,10 @@ export const transformInvoiceToPdfTemplate = (
label: tax.name,
amount: tax.taxRateAmountFormatted,
})),
discount: invoice.discountAmountFormatted,
discountLabel: invoice.discountPercentageFormatted
? `Discount [${invoice.discountPercentageFormatted}]`
: 'Discount',
customerAddress: contactAddressTextFormat(invoice.customer),
};
};

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),
};
};