Skip to content

Commit

Permalink
Merge pull request #723 from bigcapitalhq/invoice-mail-receipt
Browse files Browse the repository at this point in the history
feat: wip Invoice mail receipt preview
  • Loading branch information
abouolia authored Oct 31, 2024
2 parents 26088a7 + dbbaa38 commit 6871118
Show file tree
Hide file tree
Showing 86 changed files with 12,237 additions and 2,781 deletions.
1 change: 1 addition & 0 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"@aws-sdk/client-s3": "^3.576.0",
"@aws-sdk/s3-request-presigner": "^3.583.0",
"@bigcapital/utils": "*",
"@bigcapital/email-components": "*",
"@casl/ability": "^5.4.3",
"@hapi/boom": "^7.4.3",
"@lemonsqueezy/lemonsqueezy.js": "^2.2.0",
Expand Down
23 changes: 17 additions & 6 deletions packages/server/src/api/controllers/Sales/SalesInvoices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,18 +179,29 @@ export default class SaleInvoicesController extends BaseController {
'/:id/mail',
[
...this.specificSaleInvoiceValidation,
body('subject').isString().optional(),

body('subject').isString().optional({ nullable: true }),
body('message').isString().optional({ nullable: true }),

body('from').isString().optional(),
body('to').isString().optional(),
body('body').isString().optional(),

body('to').isArray().exists(),
body('to.*').isString().isEmail().optional(),

body('cc').isArray().optional({ nullable: true }),
body('cc.*').isString().isEmail().optional(),

body('bcc').isArray().optional({ nullable: true }),
body('bcc.*').isString().isEmail().optional(),

body('attach_invoice').optional().isBoolean().toBoolean(),
],
this.validationResult,
asyncMiddleware(this.sendSaleInvoiceMail.bind(this)),
this.handleServiceErrors
);
router.get(
'/:id/mail',
'/:id/mail/state',
[...this.specificSaleInvoiceValidation],
this.validationResult,
asyncMiddleware(this.getSaleInvoiceMail.bind(this)),
Expand Down Expand Up @@ -778,7 +789,7 @@ export default class SaleInvoicesController extends BaseController {
}

/**
* Retrieves the default mail options of the given sale invoice.
* Retrieves the mail state of the given sale invoice.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
Expand All @@ -792,7 +803,7 @@ export default class SaleInvoicesController extends BaseController {
const { id: invoiceId } = req.params;

try {
const data = await this.saleInvoiceApplication.getSaleInvoiceMail(
const data = await this.saleInvoiceApplication.getSaleInvoiceMailState(
tenantId,
invoiceId
);
Expand Down
3 changes: 3 additions & 0 deletions packages/server/src/constants/event-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ export const SALE_INVOICE_DELETED = 'Sale invoice deleted';
export const SALE_INVOICE_MAIL_DELIVERED = 'Sale invoice mail delivered';
export const SALE_INVOICE_VIEWED = 'Sale invoice viewed';
export const SALE_INVOICE_PDF_VIEWED = 'Sale invoice PDF viewed';
export const SALE_INVOICE_MAIL_SENT = 'Sale invoice mail sent';
export const SALE_INVOICE_MAIL_REMINDER_SENT =
'Sale invoice reminder mail sent';

export const SALE_ESTIMATE_CREATED = 'Sale estimate created';
export const SALE_ESTIMATE_EDITED = 'Sale estimate edited';
Expand Down
16 changes: 6 additions & 10 deletions packages/server/src/interfaces/Mailable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,14 @@ export interface AddressItem {
}

export interface CommonMailOptions {
toAddresses: AddressItem[];
fromAddresses: AddressItem[];
from: string;
to: string | string[];
from: Array<string>;
subject: string;
body: string;
message: string;
to: Array<string>;
cc?: Array<string>;
bcc?: Array<string>;
data?: Record<string, any>;
}

export interface CommonMailOptionsDTO {
to?: string | string[];
from?: string;
subject?: string;
body?: string;
export interface CommonMailOptionsDTO extends Partial<CommonMailOptions> {
}
28 changes: 27 additions & 1 deletion packages/server/src/interfaces/SaleInvoice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,32 @@ export enum SaleInvoiceAction {
}

export interface SaleInvoiceMailOptions extends CommonMailOptions {
attachInvoice: boolean;
attachInvoice?: boolean;
formatArgs?: Record<string, any>;
}

export interface SaleInvoiceMailState extends SaleInvoiceMailOptions {
invoiceNo: string;

invoiceDate: string;
invoiceDateFormatted: string;

dueDate: string;
dueDateFormatted: string;

total: number;
totalFormatted: string;

subtotal: number;
subtotalFormatted: number;

companyName: string;
companyLogoUri: string;

customerName: string;

// # Invoice entries
entries?: Array<{ label: string; total: string; quantity: string | number }>;
}

export interface SendInvoiceMailDTO extends CommonMailOptionsDTO {
Expand All @@ -251,6 +276,7 @@ export interface ISaleInvoiceMailSend {
tenantId: number;
saleInvoiceId: number;
messageOptions: SendInvoiceMailDTO;
formattedMessageOptions: SaleInvoiceMailOptions;
}

export interface ISaleInvoiceMailSent {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -43,6 +44,10 @@ export class SaleInvoiceEventsTracker extends EventSubscriber {
events.saleInvoice.onPdfViewed,
this.handleTrackPdfViewedInvoiceEvent
);
bus.subscribe(
events.saleInvoice.onMailSent,
this.handleTrackMailSentInvoiceEvent
);
}

private handleTrackInvoiceCreatedEvent = ({
Expand Down Expand Up @@ -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: {},
});
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
};
}

Expand All @@ -100,7 +79,7 @@ export class ContactMailNotification {
.withGraphFetched('metadata');

return {
CompanyName: organization.metadata.name,
['Company Name']: organization.metadata.name,
};
}
}
37 changes: 25 additions & 12 deletions packages/server/src/services/MailNotification/utils.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading

0 comments on commit 6871118

Please sign in to comment.