diff --git a/src/modules/announcements/queue/announcment.processor.ts b/src/modules/announcements/queue/announcment.processor.ts index 5c8e1d1..eb678e4 100755 --- a/src/modules/announcements/queue/announcment.processor.ts +++ b/src/modules/announcements/queue/announcment.processor.ts @@ -32,13 +32,13 @@ export class AnnouncementProcessor extends WorkerProcessor { } } //********************************** */ - private async sendAnnouncement(job: Job, token?: string) { + private async sendAnnouncement(job: Job, token?: string) { this.logger.log(`Sending announcement: ${job.data.announcementId} to users. ${token}`); const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); - const data = job.data as ISendAnnouncement; + const data = job.data; try { // Create a query builder to fetch unique users diff --git a/src/modules/invoices/constants/index.ts b/src/modules/invoices/constants/index.ts new file mode 100644 index 0000000..426be20 --- /dev/null +++ b/src/modules/invoices/constants/index.ts @@ -0,0 +1,15 @@ +export const INVOICE = Object.freeze({ + INVOICE_QUEUE_NAME: "invoice", + INVOICE_REMINDER_JOB_NAME: "reminderInvoice", + // + INVOICE_REMINDER_JOB_PRIORITY: 1, // High priority + INVOICE_REMINDER_JOB_ATTEMPTS: 3, // Retry 3 times + INVOICE_REMINDER_JOB_BACKOFF: 1000, // Retry after 1 second + INVOICE_REMINDER_JOB_TIMEOUT: 10000, // Timeout after 10 seconds + INVOICE_REMINDER_REPEAT_DELAY: 86400000, // 1 day delay in milliseconds + // + FINE_PERCENTAGE: 0.01, // 1% of the total price + MAX_DAYS_AFTER_OVERDUE: 10, + DUEDATE: 7, + DAYS_BEFORE_OVERDUE: 3, +}); diff --git a/src/modules/invoices/entities/invoice.entity.ts b/src/modules/invoices/entities/invoice.entity.ts index 0c98210..9979c4e 100755 --- a/src/modules/invoices/entities/invoice.entity.ts +++ b/src/modules/invoices/entities/invoice.entity.ts @@ -32,6 +32,9 @@ export class Invoice extends BaseEntity { @Column({ type: "decimal", precision: 16, scale: 2, nullable: false, transformer: new DecimalTransformer() }) tax: Decimal; + @Column({ type: "decimal", precision: 16, scale: 2, nullable: true, transformer: new DecimalTransformer() }) + lateFee?: Decimal; + //--------------------------------------------- @OneToMany(() => InvoiceItem, (invoiceItem) => invoiceItem.invoice, { cascade: true }) diff --git a/src/modules/invoices/invoices.module.ts b/src/modules/invoices/invoices.module.ts index c9b075f..7a47fa5 100755 --- a/src/modules/invoices/invoices.module.ts +++ b/src/modules/invoices/invoices.module.ts @@ -1,6 +1,8 @@ +import { BullModule } from "@nestjs/bullmq"; import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; +import { INVOICE } from "./constants"; import { InvoiceItem } from "./entities/invoice-item.entity"; import { Invoice } from "./entities/invoice.entity"; import { InvoicesController } from "./invoices.controller"; @@ -14,16 +16,18 @@ import { NotificationModule } from "../notifications/notifications.module"; import { UsersModule } from "../users/users.module"; import { UtilsModule } from "../utils/utils.module"; import { WalletsModule } from "../wallets/wallets.module"; +import { InvoiceProcessor } from "./queue/invoice.processor"; @Module({ imports: [ + BullModule.registerQueue({ name: INVOICE.INVOICE_QUEUE_NAME }), TypeOrmModule.forFeature([Invoice, InvoiceItem, Discount, UsageDiscount]), UsersModule, WalletsModule, UtilsModule, NotificationModule, ], - providers: [InvoicesService, InvoicesRepository, DiscountRepository, UsageDiscountRepository], + providers: [InvoicesService, InvoicesRepository, DiscountRepository, UsageDiscountRepository, InvoiceProcessor], controllers: [InvoicesController], exports: [InvoicesService], }) diff --git a/src/modules/invoices/providers/invoices.service.ts b/src/modules/invoices/providers/invoices.service.ts index 3b54ecd..ecc91c8 100755 --- a/src/modules/invoices/providers/invoices.service.ts +++ b/src/modules/invoices/providers/invoices.service.ts @@ -1,5 +1,7 @@ +import { InjectQueue } from "@nestjs/bullmq"; import { BadRequestException, Injectable, Logger } from "@nestjs/common"; // eslint-disable-next-line import/no-named-as-default +import { Queue } from "bullmq"; import dayjs from "dayjs"; // eslint-disable-next-line import/no-named-as-default import Decimal from "decimal.js"; @@ -18,6 +20,7 @@ import { PaginationUtils } from "../../utils/providers/pagination.utils"; import { SmsService } from "../../utils/providers/sms.service"; import { Wallet } from "../../wallets/entities/wallet.entity"; import { WalletsService } from "../../wallets/providers/wallets.service"; +import { INVOICE } from "../constants"; import { CreateInvoiceDto } from "../DTO/create-invoice.dto"; import { InvoicesSearchQueryDto, UserInvoicesSearchQueryDto } from "../DTO/invoices-search-query.dto"; import { Invoice } from "../entities/invoice.entity"; @@ -29,6 +32,7 @@ export class InvoicesService { private readonly logger = new Logger(InvoicesService.name); constructor( + @InjectQueue(INVOICE.INVOICE_QUEUE_NAME) private readonly invoiceQueue: Queue, private readonly notificationsService: NotificationsService, private readonly invoiceRepository: InvoicesRepository, private readonly usersService: UsersService, @@ -58,8 +62,8 @@ export class InvoicesService { const totalPrice = invoiceItems.reduce((sum, item) => new Decimal(item.totalPrice).add(sum), new Decimal(0)); const tax = totalPrice.mul(0.1); - const dueDate = new Date(); - dueDate.setDate(dueDate.getDate() + 5); + //TODO:for test use minutes + const dueDate = dayjs().add(INVOICE.DUEDATE, "day").toDate(); const invoice = queryRunner.manager.create(Invoice, { user: { id: createDto.userId }, @@ -88,6 +92,17 @@ export class InvoicesService { queryRunner, ); + await this.invoiceQueue.add( + INVOICE.INVOICE_REMINDER_JOB_NAME, + { invoiceId: invoice.id }, + { + delay: dayjs(invoice.dueDate).subtract(3, "day").diff(dayjs()), + attempts: INVOICE.INVOICE_REMINDER_JOB_ATTEMPTS, + backoff: INVOICE.INVOICE_REMINDER_JOB_BACKOFF, + repeat: { every: 300000 }, // Repeat every 5 minutes (300,000 ms) + }, + ); + await queryRunner.commitTransaction(); return { @@ -236,6 +251,17 @@ export class InvoicesService { qryRnr, ); + await this.invoiceQueue.add( + INVOICE.INVOICE_REMINDER_JOB_NAME, + { invoiceId: invoice.id }, + { + delay: dayjs(invoice.dueDate).subtract(INVOICE.DAYS_BEFORE_OVERDUE, "day").diff(dayjs()), + attempts: INVOICE.INVOICE_REMINDER_JOB_ATTEMPTS, + backoff: INVOICE.INVOICE_REMINDER_JOB_BACKOFF, + repeat: { every: 300000 }, + }, + ); + return invoice; } diff --git a/src/modules/invoices/queue/invoice.processor.ts b/src/modules/invoices/queue/invoice.processor.ts new file mode 100644 index 0000000..41af616 --- /dev/null +++ b/src/modules/invoices/queue/invoice.processor.ts @@ -0,0 +1,157 @@ +import { InjectQueue, Processor } from "@nestjs/bullmq"; +import { Logger } from "@nestjs/common"; +import { Job, Queue } from "bullmq"; +import dayjs from "dayjs"; +// eslint-disable-next-line import/no-named-as-default +import Decimal from "decimal.js"; +import { DataSource, QueryRunner } from "typeorm"; + +import { WorkerProcessor } from "../../../common/queues/worker.processor"; +import { NotificationsService } from "../../notifications/providers/notifications.service"; +import { INVOICE } from "../constants"; +import { Invoice } from "../entities/invoice.entity"; +import { InvoiceStatus } from "../enums/invoice-status.enum"; + +@Processor(INVOICE.INVOICE_QUEUE_NAME) +export class InvoiceProcessor extends WorkerProcessor { + protected readonly logger = new Logger(InvoiceProcessor.name); + private readonly FINE_PERCENTAGE = 0.01; + private readonly MAX_DAYS_OVERDUE = 1; + + constructor( + @InjectQueue(INVOICE.INVOICE_QUEUE_NAME) private readonly invoiceQueue: Queue, + private readonly dataSource: DataSource, + private readonly notificationService: NotificationsService, + ) { + super(); + } + + async process(job: Job, token?: string) { + this.logger.log(job, token); + switch (job.name) { + case INVOICE.INVOICE_REMINDER_JOB_NAME: + return this.sendBillInvoiceReminder(job, token); + default: + this.logger.error(`Unknown job name: ${job.name}`); + return; + } + } + //********************************** */ + + private async sendBillInvoiceReminder(job: Job<{ invoiceId: string }>, token?: string) { + this.logger.log(`Sending bill invoice reminder: ${job.data.invoiceId} to user. ${token}`); + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const invoice = await this.fetchInvoice(job.data.invoiceId, queryRunner); + + if (invoice.status === InvoiceStatus.PAID) { + this.logger.log(`Invoice ${invoice.id} is already paid.`); + await queryRunner.commitTransaction(); + return; + } + + if (this.isInvoiceOverdueForCancellation(invoice)) { + await this.cancelOverdueInvoice(invoice, queryRunner); + await queryRunner.commitTransaction(); + return; + } + + await this.applyLateFeeIfOverdue(invoice, queryRunner); + + await this.sendReminderNotification(invoice, queryRunner); + + await this.scheduleNextReminder(invoice.id); + + await queryRunner.commitTransaction(); + } catch (error) { + this.logger.error(`Failed to process invoice reminder:`, error); + await queryRunner.rollbackTransaction(); + throw error; + } finally { + await queryRunner.release(); + } + } + //********************************** */ + private async fetchInvoice(invoiceId: string, queryRunner: QueryRunner) { + const invoice = await queryRunner.manager.findOne(Invoice, { + where: { id: invoiceId }, + relations: { user: true }, + }); + + if (!invoice) throw new Error(`Invoice not found: ${invoiceId}`); + return invoice; + } + //********************************** */ + + private isInvoiceOverdueForCancellation(invoice: Invoice): boolean { + const fiveDaysAfterDueDate = dayjs(invoice.dueDate).add(this.MAX_DAYS_OVERDUE, "day"); + return dayjs().isAfter(fiveDaysAfterDueDate); + } + //********************************** */ + + private async cancelOverdueInvoice(invoice: Invoice, queryRunner: QueryRunner) { + invoice.status = InvoiceStatus.CANCELLED; + await queryRunner.manager.save(Invoice, invoice); + this.logger.log(`Invoice ${invoice.id} has been cancelled as it is more than 5 days overdue.`); + } + //********************************** */ + + private async applyLateFeeIfOverdue(invoice: Invoice, queryRunner: QueryRunner) { + const today = dayjs(); + const dueDate = dayjs(invoice.dueDate); + + if (today.isAfter(dueDate)) { + const daysOverdue = Math.min(today.diff(dueDate, "day"), this.MAX_DAYS_OVERDUE); + + const currentFine = invoice.lateFee || new Decimal(0); + const totalDaysFineShouldBeApplied = daysOverdue; + const finePerDay = new Decimal(invoice.totalPrice).mul(this.FINE_PERCENTAGE); + const newTotalFine = finePerDay.mul(totalDaysFineShouldBeApplied); + + if (newTotalFine.gt(currentFine)) { + this.logger.log(`Applying late fee for invoice ${invoice.id}: ${newTotalFine} (${daysOverdue} days overdue)`); + invoice.lateFee = newTotalFine; + invoice.totalPrice = new Decimal(invoice.totalPrice).plus(newTotalFine.minus(currentFine)); + await queryRunner.manager.save(Invoice, invoice); + } + } + } + //********************************** */ + + private async sendReminderNotification(invoice: Invoice, queryRunner: QueryRunner) { + this.logger.log(`Sending invoice reminder to user: ${invoice.user.id}`); + await this.notificationService.createBillInvoiceReminderNotification( + invoice.user.id, + { + userPhone: invoice.user.phone, + userEmail: invoice.user.email, + invoiceId: invoice.numericId.toString(), + price: new Decimal(invoice.totalPrice).toNumber(), + dueDate: invoice.dueDate, + createDate: invoice.createdAt, + items: invoice.items.join(", "), + }, + queryRunner, + ); + } + //********************************** */ + + private async scheduleNextReminder(invoiceId: string) { + this.logger.log(`Invoice ${invoiceId} is still unpaid. Scheduling a new reminder.`); + await this.invoiceQueue.add( + INVOICE.INVOICE_REMINDER_JOB_NAME, + { invoiceId }, + { + delay: INVOICE.INVOICE_REMINDER_REPEAT_DELAY, // 1 day delay in milliseconds + attempts: INVOICE.INVOICE_REMINDER_JOB_ATTEMPTS, + backoff: INVOICE.INVOICE_REMINDER_JOB_BACKOFF, + }, + ); + } + + //********************************** */ +} diff --git a/src/modules/subscriptions/providers/subscriptions.service.ts b/src/modules/subscriptions/providers/subscriptions.service.ts index 009cb5c..0cf8729 100755 --- a/src/modules/subscriptions/providers/subscriptions.service.ts +++ b/src/modules/subscriptions/providers/subscriptions.service.ts @@ -4,6 +4,7 @@ import { DataSource, In } from "typeorm"; import { ServiceMessage, SubscriptionMessage } from "../../../common/enums/message.enum"; import { DanakServicesService } from "../../danak-services/providers/danak-services.service"; +import { INVOICE } from "../../invoices/constants"; import { InvoicesService } from "../../invoices/providers/invoices.service"; import { UsersService } from "../../users/providers/users.service"; import { PaginationUtils } from "../../utils/providers/pagination.utils"; @@ -205,8 +206,7 @@ export class SubscriptionsService { await queryRunner.manager.save(UserSubscription, userSubscription); // - //TODO: need queue handling for notification - const invoiceDueDate = dayjs(userSubscription.startDate).add(5, "day").toDate(); + const invoiceDueDate = dayjs(userSubscription.startDate).add(INVOICE.DUEDATE, "day").toDate(); const invoice = await this.invoicesService.createInvoiceForSubscription(user, plan, userSubscription, invoiceDueDate, queryRunner); userSubscription.status = SubscriptionStatus.INACTIVE;