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 { SubscriptionStatus } from "../../subscriptions/enums/subscription-status.enum"; 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); 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, items: { subscriptionPlan: true } }, }); if (!invoice) throw new Error(`Invoice not found: ${invoiceId}`); return invoice; } //********************************** */ private isInvoiceOverdueForCancellation(invoice: Invoice): boolean { const tenDaysAfterDueDate = dayjs(invoice.dueDate).add(INVOICE.MAX_DAYS_AFTER_OVERDUE, "day"); return dayjs().isAfter(tenDaysAfterDueDate); } //********************************** */ private async cancelOverdueInvoice(invoice: Invoice, queryRunner: QueryRunner) { invoice.status = InvoiceStatus.CANCELLED; await queryRunner.manager.save(Invoice, invoice); if (invoice.items[0]?.subscriptionPlan) { this.logger.log( `Invoice ${invoice.id} has been cancelled as it is more than 10 days overdue. Subscription Plan ${invoice.items[0].subscriptionPlan.id} has been cancelled.`, ); const userSubscription = invoice.items[0].subscriptionPlan; userSubscription.status = SubscriptionStatus.INACTIVE; await queryRunner.manager.save(userSubscription); // await this.notificationService.createSubscriptionPlanCancelledNotification( // invoice.user.id, // { // userPhone: invoice.user.phone, // userEmail: invoice.user.email, // subscriptionPlanId: invoice.items[0].subscriptionPlan.id, // }, // queryRunner, // ); } this.logger.log(`Invoice ${invoice.id} has been cancelled as it is more than 10 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"), INVOICE.MAX_DAYS_AFTER_OVERDUE); const currentFine = invoice.lateFee || new Decimal(0); const totalDaysFineShouldBeApplied = daysOverdue; const finePerDay = new Decimal(invoice.totalPrice).mul(INVOICE.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, }, ); } //********************************** */ }