refactor: the invoice queue logic

This commit is contained in:
mahyargdz
2025-04-21 14:51:54 +03:30
parent c330428ce5
commit e7d547f19b
10 changed files with 121 additions and 31 deletions
+3 -3
View File
@@ -7,13 +7,13 @@ import { ConfigModule } from "@nestjs/config";
import { ThrottlerModule } from "@nestjs/throttler"; import { ThrottlerModule } from "@nestjs/throttler";
import { TypeOrmModule } from "@nestjs/typeorm"; import { TypeOrmModule } from "@nestjs/typeorm";
import { MailerModule } from "@nestjs-modules/mailer"; import { MailerModule } from "@nestjs-modules/mailer";
// import { TelegrafModule } from "nestjs-telegraf"; import { TelegrafModule } from "nestjs-telegraf";
import { bullMqConfig } from "./configs/bullmq.config"; import { bullMqConfig } from "./configs/bullmq.config";
import { cacheConfig } from "./configs/cache.config"; import { cacheConfig } from "./configs/cache.config";
import { mailerConfig } from "./configs/mailer.config"; import { mailerConfig } from "./configs/mailer.config";
import { rateLimitConfig } from "./configs/rateLimit.config"; import { rateLimitConfig } from "./configs/rateLimit.config";
// import { telegrafConfig } from "./configs/telegraf.config"; import { telegrafConfig } from "./configs/telegraf.config";
import { databaseConfigs } from "./configs/typeorm.config"; import { databaseConfigs } from "./configs/typeorm.config";
import { HTTPLogger } from "./core/middlewares/logger.middleware"; import { HTTPLogger } from "./core/middlewares/logger.middleware";
import { AddressModule } from "./modules/address/address.module"; import { AddressModule } from "./modules/address/address.module";
@@ -42,7 +42,7 @@ import { WalletsModule } from "./modules/wallets/wallets.module";
@Module({ @Module({
imports: [ imports: [
// TelegrafModule.forRootAsync(telegrafConfig()), TelegrafModule.forRootAsync(telegrafConfig()),
MailerModule.forRootAsync(mailerConfig()), MailerModule.forRootAsync(mailerConfig()),
BullModule.forRootAsync(bullMqConfig()), BullModule.forRootAsync(bullMqConfig()),
ThrottlerModule.forRootAsync(rateLimitConfig()), ThrottlerModule.forRootAsync(rateLimitConfig()),
+2
View File
@@ -382,6 +382,8 @@ export const enum NotificationMessage {
BILL_INVOICE_MESSAGE = "صورت حساب شماره [invoiceNumber] با موفقیت پرداخت شد", BILL_INVOICE_MESSAGE = "صورت حساب شماره [invoiceNumber] با موفقیت پرداخت شد",
APPROVED_INVOICE_MESSAGE = "صورت حساب شماره [invoiceNumber] تایید شد", APPROVED_INVOICE_MESSAGE = "صورت حساب شماره [invoiceNumber] تایید شد",
APPROVED_INVOICE = "تایید صورت حساب", APPROVED_INVOICE = "تایید صورت حساب",
INVOICE_OVERDUE = "صورت حساب معوق",
INVOICE_OVERDUE_MESSAGE = "صورت حساب شماره [invoiceNumber] معوق شده و دارای مبلغ [lateFee] جریمه تاخیر است",
CREATE_SERVICE = "ایجاد سرویس", CREATE_SERVICE = "ایجاد سرویس",
CREATE_SERVICE_MESSAGE = "سرویس جدیدی با عنوان [serviceName] ایجاد شد", CREATE_SERVICE_MESSAGE = "سرویس جدیدی با عنوان [serviceName] ایجاد شد",
UNBLOCK_SERVICE = "فعال‌سازی سرویس", UNBLOCK_SERVICE = "فعال‌سازی سرویس",
+4
View File
@@ -20,6 +20,8 @@ export function smsConfigs() {
SMS_PATTERN_INVOICE_APPROVED: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_APPROVED"), SMS_PATTERN_INVOICE_APPROVED: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_APPROVED"),
SMS_PATTERN_INVOICE_PAID: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_PAID"), SMS_PATTERN_INVOICE_PAID: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_PAID"),
SMS_PATTERN_INVOICE_PAYMENT_REMINDER: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_PAYMENT_REMINDER"), SMS_PATTERN_INVOICE_PAYMENT_REMINDER: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_PAYMENT_REMINDER"),
SMS_PATTERN_INVOICE_REMINDER: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_REMINDER"),
SMS_PATTERN_INVOICE_OVERDUE: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_OVERDUE"),
SMS_PATTERN_RECURRING_INVOICE_DRAFT: configService.getOrThrow<string>("SMS_PATTERN_RECURRING_INVOICE_DRAFT"), SMS_PATTERN_RECURRING_INVOICE_DRAFT: configService.getOrThrow<string>("SMS_PATTERN_RECURRING_INVOICE_DRAFT"),
SMS_PATTERN_SUBSCRIPTION_CANCELLED: configService.getOrThrow<string>("SMS_PATTERN_SUBSCRIPTION_CANCELLED"), SMS_PATTERN_SUBSCRIPTION_CANCELLED: configService.getOrThrow<string>("SMS_PATTERN_SUBSCRIPTION_CANCELLED"),
}; };
@@ -43,6 +45,8 @@ export interface ISmsConfigs {
SMS_PATTERN_INVOICE_APPROVED: string; SMS_PATTERN_INVOICE_APPROVED: string;
SMS_PATTERN_INVOICE_PAID: string; SMS_PATTERN_INVOICE_PAID: string;
SMS_PATTERN_INVOICE_PAYMENT_REMINDER: string; SMS_PATTERN_INVOICE_PAYMENT_REMINDER: string;
SMS_PATTERN_INVOICE_REMINDER: string;
SMS_PATTERN_INVOICE_OVERDUE: string;
SMS_PATTERN_RECURRING_INVOICE_DRAFT: string; SMS_PATTERN_RECURRING_INVOICE_DRAFT: string;
SMS_PATTERN_SUBSCRIPTION_CANCELLED: string; SMS_PATTERN_SUBSCRIPTION_CANCELLED: string;
} }
@@ -3,6 +3,7 @@ export enum InvoiceStatus {
PENDING = "PENDING", PENDING = "PENDING",
WAIT_PAYMENT = "WAIT_PAYMENT", WAIT_PAYMENT = "WAIT_PAYMENT",
PAID = "PAID", PAID = "PAID",
OVERDUE = "OVERDUE",
EXPIRED = "EXPIRED", EXPIRED = "EXPIRED",
CANCELLED = "CANCELLED", CANCELLED = "CANCELLED",
} }
+38 -11
View File
@@ -18,8 +18,6 @@ import { InvoicesService } from "../providers/invoices.service";
@Processor(INVOICE.INVOICE_QUEUE_NAME, { concurrency: 5 }) @Processor(INVOICE.INVOICE_QUEUE_NAME, { concurrency: 5 })
export class InvoiceProcessor extends WorkerProcessor { export class InvoiceProcessor extends WorkerProcessor {
// protected readonly logger = new Logger(InvoiceProcessor.name);
constructor( constructor(
@InjectQueue(INVOICE.INVOICE_QUEUE_NAME) private readonly invoiceQueue: Queue, @InjectQueue(INVOICE.INVOICE_QUEUE_NAME) private readonly invoiceQueue: Queue,
private readonly invoicesService: InvoicesService, private readonly invoicesService: InvoicesService,
@@ -94,9 +92,7 @@ export class InvoiceProcessor extends WorkerProcessor {
this.logger.verbose(`Creating subscription-based recurring invoice`); this.logger.verbose(`Creating subscription-based recurring invoice`);
const userSubscriptionPlan = invoice.items[0]?.subscriptionPlan; const userSubscriptionPlan = invoice.items[0]?.subscriptionPlan;
if (!userSubscriptionPlan) { if (!userSubscriptionPlan) throw new Error(`No subscription plan found for invoice ${invoice.id}`);
throw new Error(`No subscription plan found for invoice ${invoice.id}`);
}
const { plan } = userSubscriptionPlan; const { plan } = userSubscriptionPlan;
const dueDate = dayjs().add(INVOICE.DUEDATE, "day").toDate(); const dueDate = dayjs().add(INVOICE.DUEDATE, "day").toDate();
@@ -192,18 +188,49 @@ export class InvoiceProcessor extends WorkerProcessor {
const dueDate = dayjs(invoice.dueDate); const dueDate = dayjs(invoice.dueDate);
if (today.isAfter(dueDate)) { if (today.isAfter(dueDate)) {
// Mark invoice as overdue in status if not already paid
if (invoice.status === InvoiceStatus.PENDING || invoice.status === InvoiceStatus.WAIT_PAYMENT) {
this.logger.log(`Marking invoice ${invoice.id} as overdue`);
invoice.status = InvoiceStatus.OVERDUE;
}
const daysOverdue = Math.min(today.diff(dueDate, "day"), INVOICE.MAX_DAYS_AFTER_OVERDUE); const daysOverdue = Math.min(today.diff(dueDate, "day"), INVOICE.MAX_DAYS_AFTER_OVERDUE);
// Get the original price without late fees to calculate the fine correctly
const originalPrice = invoice.originalPrice || invoice.totalPrice;
const currentFine = invoice.lateFee || new Decimal(0); const currentFine = invoice.lateFee || new Decimal(0);
const totalDaysFineShouldBeApplied = daysOverdue; const finePerDay = new Decimal(originalPrice).mul(INVOICE.FINE_PERCENTAGE);
const finePerDay = new Decimal(invoice.totalPrice).mul(INVOICE.FINE_PERCENTAGE); const newTotalFine = finePerDay.mul(daysOverdue);
const newTotalFine = finePerDay.mul(totalDaysFineShouldBeApplied);
if (newTotalFine.gt(currentFine)) { if (newTotalFine.gt(currentFine)) {
this.logger.log(`Applying late fee for invoice ${invoice.id}: ${newTotalFine} (${daysOverdue} days overdue)`); this.logger.log(`Applying late fee for invoice ${invoice.id}: ${newTotalFine} (${daysOverdue} days overdue)`);
// Calculate the difference between new and current fine
const additionalFine = newTotalFine.minus(currentFine);
// Update the late fee
invoice.lateFee = newTotalFine; invoice.lateFee = newTotalFine;
invoice.totalPrice = new Decimal(invoice.totalPrice).plus(newTotalFine.minus(currentFine));
// Add only the additional fine to total price to avoid compounding
invoice.totalPrice = new Decimal(invoice.totalPrice).plus(additionalFine);
await queryRunner.manager.save(Invoice, invoice); await queryRunner.manager.save(Invoice, invoice);
// Send overdue notification to the user
await this.notificationService.createInvoiceOverdueNotification(
invoice.user.id,
{
userPhone: invoice.user.phone,
userEmail: invoice.user.email,
invoiceId: invoice.numericId.toString(),
price: new Decimal(invoice.totalPrice).toNumber(),
lateFee: new Decimal(newTotalFine).toNumber(),
dueDate: invoice.dueDate,
createDate: invoice.createdAt,
items: invoice.items.map((item) => item.name).join(", "),
},
queryRunner,
);
} }
} }
} }
@@ -220,7 +247,7 @@ export class InvoiceProcessor extends WorkerProcessor {
price: new Decimal(invoice.totalPrice).toNumber(), price: new Decimal(invoice.totalPrice).toNumber(),
dueDate: invoice.dueDate, dueDate: invoice.dueDate,
createDate: invoice.createdAt, createDate: invoice.createdAt,
items: invoice.items.join(", "), items: invoice.items.map((item) => item.name).join(", "),
}, },
queryRunner, queryRunner,
); );
@@ -258,7 +285,7 @@ export class InvoiceProcessor extends WorkerProcessor {
price: new Decimal(invoice.totalPrice).toNumber(), price: new Decimal(invoice.totalPrice).toNumber(),
dueDate: invoice.dueDate, dueDate: invoice.dueDate,
createDate: invoice.createdAt, createDate: invoice.createdAt,
items: invoice.items.join(", "), items: invoice.items.map((item) => item.name).join(", "),
}; };
await Promise.all( await Promise.all(
@@ -10,6 +10,7 @@ export interface IInvoiceNotificationData extends IBaseNotificationData {
createDate: Date; createDate: Date;
invoiceId: string; invoiceId: string;
paidAt?: Date; paidAt?: Date;
lateFee?: number;
} }
export interface IServiceNotificationData extends IBaseNotificationData { export interface IServiceNotificationData extends IBaseNotificationData {
@@ -315,7 +315,27 @@ export class NotificationsService {
); );
} }
// //************************ */ //************************ */
async createInvoiceOverdueNotification(recipientId: string, data: IInvoiceNotificationData, queryRunner: QueryRunner) {
const message = NotificationMessage.INVOICE_OVERDUE_MESSAGE.replace("[invoiceNumber]", data.invoiceId).replace(
"[lateFee]",
numberFormat(data.lateFee || 0),
);
//sent notification based on the userSettings
const { isActive } = await this.userSettingsService.getUserNotificationSettingWithType(NotifType.INVOICE_OVERDUE, recipientId);
if (isActive) {
await this.smsService.sendInvoiceOverdueSms(data.userPhone, data.invoiceId, data.price, data.lateFee || 0, dateFormat(data.dueDate));
//send email too
}
return this.createNotification(
{ title: NotificationMessage.INVOICE_OVERDUE, type: NotifType.INVOICE_OVERDUE, message, recipientId },
queryRunner,
);
}
//************************ */
async createRecurringInvoiceNotification(recipientId: string, data: IInvoiceNotificationData, queryRunner: QueryRunner) { async createRecurringInvoiceNotification(recipientId: string, data: IInvoiceNotificationData, queryRunner: QueryRunner) {
const message = NotificationMessage.RECURRING_INVOICE_MESSAGE.replace("[invoiceNumber]", data.invoiceId); const message = NotificationMessage.RECURRING_INVOICE_MESSAGE.replace("[invoiceNumber]", data.invoiceId);
@@ -21,6 +21,7 @@ export enum NotifType {
BILL_INVOICE = "BILL_INVOICE", BILL_INVOICE = "BILL_INVOICE",
APPROVE_INVOICE = "APPROVE_INVOICE", APPROVE_INVOICE = "APPROVE_INVOICE",
CREATE_INVOICE = "CREATE_INVOICE", CREATE_INVOICE = "CREATE_INVOICE",
INVOICE_OVERDUE = "INVOICE_OVERDUE",
// Service category // Service category
CREATE_SERVICE = "CREATE_SERVICE", CREATE_SERVICE = "CREATE_SERVICE",
@@ -50,6 +51,7 @@ export const NotifDescriptions: Record<NotifType, { fa: string; category: NotifC
[NotifType.BILL_INVOICE]: { fa: "پرداخت صورت حساب", category: NotifCategory.INVOICE }, [NotifType.BILL_INVOICE]: { fa: "پرداخت صورت حساب", category: NotifCategory.INVOICE },
[NotifType.APPROVE_INVOICE]: { fa: "تایید صورت حساب", category: NotifCategory.INVOICE }, [NotifType.APPROVE_INVOICE]: { fa: "تایید صورت حساب", category: NotifCategory.INVOICE },
[NotifType.RECURRING_INVOICE]: { fa: "صورت حساب دوره ای", category: NotifCategory.INVOICE }, [NotifType.RECURRING_INVOICE]: { fa: "صورت حساب دوره ای", category: NotifCategory.INVOICE },
[NotifType.INVOICE_OVERDUE]: { fa: "صورت حساب معوق", category: NotifCategory.INVOICE },
// Service category // Service category
[NotifType.CREATE_SERVICE]: { fa: "ایجاد سرویس", category: NotifCategory.SERVICE }, [NotifType.CREATE_SERVICE]: { fa: "ایجاد سرویس", category: NotifCategory.SERVICE },
+2
View File
@@ -40,6 +40,8 @@ export type TemplateParams =
| "newBalance" | "newBalance"
| "reason" | "reason"
| "dueDate" | "dueDate"
| "lateFee"
| "paidDate"
| "user" | "user"
| "subject" | "subject"
| "planName" | "planName"
+47 -16
View File
@@ -366,12 +366,12 @@ export class SmsService {
} }
} }
//************************************************* */ //************************************************* */
async sendInvoicePaidSms(mobile: string, invoiceId: string, price: number, date: string) { async sendInvoicePaidSms(mobile: string, invoiceId: string, amount: number, paidDate: string) {
const smsData: ISmsVerifyBody = { const smsData: ISmsVerifyBody = {
Parameters: [ Parameters: [
{ name: "invoiceId", value: invoiceId }, { name: "invoiceId", value: invoiceId },
{ name: "price", value: Intl.NumberFormat("fa-IR").format(price) }, { name: "amount", value: amount.toString() },
{ name: "date", value: date }, { name: "paidDate", value: paidDate },
], ],
Mobile: mobile, Mobile: mobile,
TemplateId: this.smsConfigs.SMS_PATTERN_INVOICE_PAID, TemplateId: this.smsConfigs.SMS_PATTERN_INVOICE_PAID,
@@ -399,15 +399,15 @@ export class SmsService {
//************************************************* */ //************************************************* */
async sendInvoicePaymentReminderSms(mobile: string, invoiceId: string, price: number, dueDate: string) { async sendInvoicePaymentReminderSms(mobile: string, invoiceId: string, amount: number, dueDate: string) {
const smsData: ISmsVerifyBody = { const smsData: ISmsVerifyBody = {
Parameters: [ Parameters: [
{ name: "invoiceId", value: invoiceId }, { name: "invoiceId", value: invoiceId },
{ name: "price", value: Intl.NumberFormat("fa-IR").format(price) }, { name: "amount", value: amount.toString() },
{ name: "dueDate", value: dueDate }, { name: "dueDate", value: dueDate },
], ],
Mobile: mobile, Mobile: mobile,
TemplateId: this.smsConfigs.SMS_PATTERN_INVOICE_PAYMENT_REMINDER, TemplateId: this.smsConfigs.SMS_PATTERN_INVOICE_REMINDER,
}; };
try { try {
@@ -418,18 +418,51 @@ export class SmsService {
}) })
.pipe( .pipe(
catchError((err: AxiosError) => { catchError((err: AxiosError) => {
this.logger.error("error in sending invoice payment reminder sms", err); this.logger.error("error in sending invoice reminder sms", err);
throw new InternalServerErrorException("error in sending invoice payment reminder sms"); throw new InternalServerErrorException("error in sending invoice reminder sms");
}), }),
), ),
); );
return data; return data;
} catch (error) { } catch (error) {
this.logger.error("error in sending sms", error); this.logger.error("error in sending sms", error);
// throw new InternalServerErrorException("error in sending sms");
} }
} }
// //************************************************* */
//************************************************* */
async sendInvoiceOverdueSms(mobile: string, invoiceId: string, amount: number, lateFee: number, dueDate: string) {
const smsData: ISmsVerifyBody = {
Parameters: [
{ name: "invoiceId", value: invoiceId },
{ name: "amount", value: amount.toString() },
{ name: "lateFee", value: lateFee.toString() },
{ name: "dueDate", value: dueDate },
],
Mobile: mobile,
TemplateId: this.smsConfigs.SMS_PATTERN_INVOICE_OVERDUE,
};
try {
const { data } = await firstValueFrom(
this.httpService
.post<ISmsVerifyResponse>(`${this.smsConfigs.API_URL}/send/verify`, smsData, {
headers: { "X-API-KEY": this.smsConfigs.API_KEY },
})
.pipe(
catchError((err: AxiosError) => {
this.logger.error("error in sending invoice overdue sms", err);
throw new InternalServerErrorException("error in sending invoice overdue sms");
}),
),
);
return data;
} catch (error) {
this.logger.error("error in sending sms", error);
}
}
//************************************************* */
async sendRecurringInvoiceDraftSms(adminMobile: string, invoiceId: string, price: number) { async sendRecurringInvoiceDraftSms(adminMobile: string, invoiceId: string, price: number) {
const smsData: ISmsVerifyBody = { const smsData: ISmsVerifyBody = {
@@ -461,7 +494,7 @@ export class SmsService {
} }
} }
// //************************************************* */ //************************************************* */
async sendBlockServiceSms(mobile: string, invoiceId: string, planName: string) { async sendBlockServiceSms(mobile: string, invoiceId: string, planName: string) {
const smsData: ISmsVerifyBody = { const smsData: ISmsVerifyBody = {
@@ -493,23 +526,21 @@ export class SmsService {
} }
} }
// //************************************************* */ //************************************************* */
// sendCreateServiceSms(userPhone: string, serviceName: any) { // sendCreateServiceSms(userPhone: string, serviceName: any) {
// throw new Error("Method not implemented."); // throw new Error("Method not implemented.");
// } // }
// //************************************************* */ //************************************************* */
// sendUnblockServiceSms(userPhone: string, serviceName: any) { // sendUnblockServiceSms(userPhone: string, serviceName: any) {
// throw new Error("Method not implemented."); // throw new Error("Method not implemented.");
// } // }
// //************************************************* */ //************************************************* */
// sendBlockServiceSms(userPhone: string, serviceName: any) { // sendBlockServiceSms(userPhone: string, serviceName: any) {
// throw new Error("Method not implemented."); // throw new Error("Method not implemented.");
// } // }
// //************************************************* */
//************************************************* */ //************************************************* */
async getSmsLines() { async getSmsLines() {