import { InjectQueue } from "@nestjs/bullmq"; import { BadRequestException, Injectable, Logger } from "@nestjs/common"; import { Queue } from "bullmq"; import dayjs from "dayjs"; import Decimal from "decimal.js"; import { Between, DataSource, In, QueryRunner } from "typeorm"; import { IUserIpAndHeaders } from "../../../common/decorators/user.decorator"; import { AuthMessage, InvoiceMessage, WalletMessage } from "../../../common/enums/message.enum"; import { OperationType } from "../../access-logs/enums/operation-type.enum"; import { UserType } from "../../access-logs/enums/user-type.enum"; import { AccessLogService } from "../../access-logs/providers/access-log.service"; import { VerifyOtpWithUserId } from "../../auth/DTO/verify-otp.dto"; import { RestaurantService } from "../../dmenu/providers/restaurant.service"; import { Discount } from "../../discounts/entities/discount.entity"; import { DiscountType } from "../../discounts/enums/discount-type.enum"; import { NotificationQueue } from "../../notifications/queue/notification.queue"; import { SubscriptionPlan } from "../../subscriptions/entities/subscription.entity"; import { UserSubscription } from "../../subscriptions/entities/user-subscription.entity"; import { SubscriptionStatus } from "../../subscriptions/enums/subscription-status.enum"; import { SupportPlan } from "../../support-plans/entities/support-plan.entity"; import { UserSupportPlan } from "../../support-plans/entities/user-support-plan.entity"; import { UserSupportPlanStatus } from "../../support-plans/enums/user-support-plan-status.enum"; import { User } from "../../users/entities/user.entity"; import { UsersService } from "../../users/providers/users.service"; import { OTPService } from "../../utils/providers/otp.service"; 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 { CreateExternalInvoiceDto, CreateInvoiceDto, InvoiceItemDto } from "../DTO/create-invoice.dto"; import { InvoicesSearchQueryDto, UserInvoicesSearchQueryDto } from "../DTO/invoices-search-query.dto"; import { UpdateInvoiceDto } from "../DTO/update-invoice.dto"; import { Invoice } from "../entities/invoice.entity"; import { RecurringPeriodEnum } from "../enums/invoice-recurring-period.enum"; import { InvoiceStatus } from "../enums/invoice-status.enum"; import { IExternalInvoiceJob, InvoicePurpose } from "../interfaces/external-invoice.interface"; import { InvoiceItemsRepository } from "../repositories/invoice-items.repository"; import { InvoicesRepository } from "../repositories/invoices.repository"; import { DkalaService } from "../../dkala/providers/dkala.service"; import { DPageService } from "../../dpage/dpage.service"; import { randomInt } from "crypto"; import { SearchResellerInvoicesQueryDto } from "../../reseller/dto/search-reseller-invoices.dto"; @Injectable() export class InvoicesService { private readonly logger = new Logger(InvoicesService.name); constructor( @InjectQueue(INVOICE.QUEUE_NAME) private readonly invoiceQueue: Queue, @InjectQueue(INVOICE.EXTERNAL_QUEUE_NAME) private readonly externalInvoiceQueue: Queue, private readonly notificationQueue: NotificationQueue, private readonly invoiceRepository: InvoicesRepository, private readonly invoiceItemsRepository: InvoiceItemsRepository, private readonly usersService: UsersService, private readonly walletsService: WalletsService, private readonly otpService: OTPService, private readonly smsService: SmsService, private readonly dataSource: DataSource, private readonly accessLogService: AccessLogService, private readonly restaurantService: RestaurantService, private readonly dkalaService: DkalaService, private readonly dpageService: DPageService, ) { } ///********************************** */ async createInvoiceAdmin(createDto: CreateInvoiceDto, userIpAndHeaders: IUserIpAndHeaders) { const queryRunner = this.dataSource.createQueryRunner(); try { await queryRunner.connect(); await queryRunner.startTransaction(); this.validateInvoiceItems(createDto.items); this.validateRecurringInvoice(createDto); const invoiceItems = createDto.items.map((item) => ({ name: item.name, count: item.count, unitPrice: item.unitPrice, discount: item.discount || 0, totalPrice: item.unitPrice * item.count - (item.unitPrice * item.count * item.discount) / 100, })); const totalPrice = invoiceItems.reduce((sum, item) => new Decimal(item.totalPrice).add(sum), new Decimal(0)); const tax = totalPrice.mul(0.1); if (totalPrice.lessThanOrEqualTo(0)) throw new BadRequestException(InvoiceMessage.TOTAL_PRICE_MUST_BE_POSITIVE); const dueDate = dayjs().add(INVOICE.DUEDATE, "day").toDate(); const invoice = queryRunner.manager.create(this.invoiceRepository.target, { user: { id: createDto.userId }, totalPrice: totalPrice.add(tax).toNumber(), originalPrice: totalPrice.add(tax).toNumber(), items: invoiceItems, tax: tax.toNumber(), dueDate, status: InvoiceStatus.PENDING, isRecurring: createDto.isRecurring, recurringPeriod: createDto.recurringPeriod, maxRecurringCycles: createDto.maxRecurringCycles, currentRecurringCycle: 0, }); await queryRunner.manager.save(this.invoiceRepository.target, invoice); const user = await this.usersService.findOneByIdWithQueryRunner(createDto.userId, queryRunner); await this.notificationQueue.addInvoiceCreationNotification(createDto.userId, { invoiceId: invoice.numericId.toString(), dueDate: invoice.dueDate, createDate: invoice.createdAt, price: new Decimal(invoice.totalPrice).toNumber(), userPhone: user.phone, userEmail: user.email, items: invoiceItems.map((item) => item.name).join(", "), paidAt: invoice.paidAt, }); await this.scheduleInvoiceJobs(invoice, createDto); await this.accessLogService.logCreate("Invoice", UserType.ADMIN, { user: { id: userIpAndHeaders.userId }, endpoint: "/invoices", requestId: userIpAndHeaders.headers["x-request-id"], ipAddress: userIpAndHeaders.ip, userAgent: userIpAndHeaders.headers["user-agent"], actionDescription: `Admin created invoice: ${invoice.numericId}`, metadata: { items: invoiceItems.map((item) => item.name).join(", "), }, }); await queryRunner.commitTransaction(); return { message: InvoiceMessage.CREATED, invoice, }; } catch (error) { await queryRunner.rollbackTransaction(); this.logger.error( `Failed to create invoice: ${error instanceof Error ? error.message : "Unknown error"}`, error instanceof Error ? error.stack : "", ); throw error; } finally { await queryRunner.release(); } } //********************************** */ async updateInvoiceAdmin(invoiceId: string, updateDto: UpdateInvoiceDto, userIpAndHeaders: IUserIpAndHeaders) { const queryRunner = this.dataSource.createQueryRunner(); try { await queryRunner.connect(); await queryRunner.startTransaction(); const invoice = await queryRunner.manager.findOne(this.invoiceRepository.target, { where: { id: invoiceId }, relations: { items: true }, }); if (!invoice) throw new BadRequestException(InvoiceMessage.NOT_FOUND_BY_ID); if (invoice.status !== InvoiceStatus.PENDING) throw new BadRequestException(InvoiceMessage.INVOICE_CAN_NOT_UPDATE); if (updateDto.items) { this.validateInvoiceItems(updateDto.items); const invoiceItemsData = updateDto.items.map((item) => ({ name: item.name, count: item.count, unitPrice: item.unitPrice, discount: item.discount || 0, totalPrice: item.unitPrice * item.count - (item.unitPrice * item.count * item.discount) / 100, })); // calculate total price and tax const totalPrice = invoiceItemsData.reduce((sum, item) => new Decimal(item.totalPrice).add(sum), new Decimal(0)); if (totalPrice.lessThanOrEqualTo(0)) throw new BadRequestException(InvoiceMessage.TOTAL_PRICE_MUST_BE_POSITIVE); const tax = totalPrice.mul(0.1); invoice.totalPrice = totalPrice.add(tax); invoice.tax = tax; // const invoiceItems = invoiceItemsData.map((itemData) => queryRunner.manager.create(this.invoiceItemsRepository.target, { ...itemData, invoice: invoice, }), ); invoice.items = invoiceItems; } if (updateDto.userId) { const user = await this.usersService.findOneByIdWithQueryRunner(updateDto.userId, queryRunner); invoice.user = user; } if (updateDto.isRecurring) { this.validateRecurringInvoice(updateDto); await this.updateScheduleInvoiceJobs(invoice, updateDto); invoice.isRecurring = updateDto.isRecurring; invoice.recurringPeriod = updateDto.recurringPeriod; invoice.maxRecurringCycles = updateDto.maxRecurringCycles ? updateDto.maxRecurringCycles : invoice.maxRecurringCycles; } // Save the updated invoice await queryRunner.manager.save(this.invoiceRepository.target, invoice); await queryRunner.commitTransaction(); await this.accessLogService.logUpdate("Invoice", invoice.id, UserType.ADMIN, { user: { id: userIpAndHeaders.userId }, endpoint: "/invoices", requestId: userIpAndHeaders.headers["x-request-id"], ipAddress: userIpAndHeaders.ip, userAgent: userIpAndHeaders.headers["user-agent"], actionDescription: `Admin updated invoice: ${invoice.numericId}`, oldValues: JSON.stringify({ ...invoice }), newValues: JSON.stringify({ ...updateDto }), }); return { message: InvoiceMessage.INVOICE_UPDATED, invoice, }; } catch (error) { await queryRunner.rollbackTransaction(); throw error; } finally { await queryRunner.release(); } } //********************************** */ async deleteInvoiceAdmin(invoiceId: string, userIpAndHeaders: IUserIpAndHeaders) { const queryRunner = this.dataSource.createQueryRunner(); try { await queryRunner.connect(); await queryRunner.startTransaction(); const invoice = await queryRunner.manager.findOne(this.invoiceRepository.target, { where: { id: invoiceId }, relations: { items: true }, }); if (!invoice) throw new BadRequestException(InvoiceMessage.NOT_FOUND_BY_ID); this.logger.log(`Deleting invoice: ${invoiceId} (numeric ID: ${invoice.numericId}, status: ${invoice.status})`); this.logger.log(`Starting cleanup of queue jobs for invoice: ${invoiceId}`); await this.cleanupInvoiceQueueJobs(invoiceId); this.logger.log(`Completed cleanup of queue jobs for invoice: ${invoiceId}`); await queryRunner.manager.remove(this.invoiceRepository.target, invoice); await this.accessLogService.logDelete("Invoice", invoice.id, UserType.ADMIN, { user: { id: userIpAndHeaders.userId }, endpoint: "/invoices", requestId: userIpAndHeaders.headers["x-request-id"], ipAddress: userIpAndHeaders.ip, userAgent: userIpAndHeaders.headers["user-agent"], actionDescription: `Admin deleted invoice: ${invoice.numericId}`, metadata: { items: (invoice.items || []).map((item) => item.name).join(", "), }, }); await queryRunner.commitTransaction(); return { message: InvoiceMessage.DELETED, }; } catch (error) { await queryRunner.rollbackTransaction(); throw error; } finally { await queryRunner.release(); } } //################################################ private async cleanupInvoiceQueueJobs(invoiceId: string): Promise { try { this.logger.log(`Starting cleanup of queue jobs for invoice: ${invoiceId}`); this.logger.log(`Removing recurring invoice jobs for invoice: ${invoiceId}`); await this.removeRecurringInvoiceJobs(invoiceId); this.logger.log(`Removing reminder invoice jobs for invoice: ${invoiceId}`); await this.removeReminderInvoiceJobs(invoiceId); this.logger.log(`Removing admin notification jobs for invoice: ${invoiceId}`); await this.removeAdminNotificationJobs(invoiceId); this.logger.log(`Checking for additional main queue jobs for invoice: ${invoiceId}`); const mainQueueJobs = await this.invoiceQueue.getJobs(["active", "waiting", "delayed", "failed", "completed"]); let mainJobsRemoved = 0; for (const job of mainQueueJobs) { const jobData = job.data; if (jobData && (jobData.invoiceId === invoiceId || (jobData.invoice && jobData.invoice.id === invoiceId))) { if ( job.name !== INVOICE.RECURRING_JOB_NAME && job.name !== INVOICE.REMINDER_JOB_NAME && job.name !== INVOICE.SUBSCRIPTION_ADMIN_NOTIFICATION_JOB_NAME ) { await job.remove(); mainJobsRemoved++; this.logger.log(`Removed additional invoice queue job: ${job.name} (ID: ${job.id}) for invoice: ${invoiceId}`); } } } this.logger.log(`Checking for external invoice queue jobs for invoice: ${invoiceId}`); const externalQueueJobs = await this.externalInvoiceQueue.getJobs(["active", "waiting", "delayed", "failed", "completed"]); let externalJobsRemoved = 0; for (const job of externalQueueJobs) { const jobData = job.data; if ( jobData && (jobData.invoiceId === invoiceId || (jobData.invoice && jobData.invoice.id === invoiceId) || (jobData.invoice && jobData.invoice.numericId === invoiceId)) ) { await job.remove(); externalJobsRemoved++; this.logger.log(`Removed external invoice queue job: ${job.name} (ID: ${job.id}) for invoice: ${invoiceId}`); } } this.logger.log( `Successfully cleaned up queue jobs for invoice: ${invoiceId}. Main jobs removed: ${mainJobsRemoved}, External jobs removed: ${externalJobsRemoved}`, ); } catch (error) { this.logger.error(`Error cleaning up queue jobs for invoice ${invoiceId}:`, error); // don't throw error here to avoid blocking invoice deletion } } //********************************** */ async approveInvoiceRequest(invoiceId: string, userIpAndHeaders: IUserIpAndHeaders) { const queryRunner = this.dataSource.createQueryRunner(); try { await queryRunner.connect(); await queryRunner.startTransaction(); const user = await this.usersService.findOneByIdWithQueryRunner(userIpAndHeaders.userId, queryRunner); const invoice = await this.validateInvoiceForApproval(invoiceId, userIpAndHeaders.userId, queryRunner); const existCode = await this.otpService.checkExistOtp(user.phone, "INVOICE_VERIFY"); if (existCode) { return { message: AuthMessage.OTP_ALREADY_SENT, ttlSecond: existCode, }; } // Generate and send OTP const otpCode = await this.otpService.generateAndSetInCache(user.phone, "INVOICE_VERIFY"); const items = invoice.items.map((item) => item.name).join(", "); await this.smsService.sendInvoiceVerifyCode( user.phone, otpCode, invoice.numericId, new Decimal(invoice.totalPrice).toNumber(), items, ); await this.accessLogService.logUserAction( OperationType.APPROVE, "Invoice", invoiceId, `user approved invoice request: ${invoice.numericId}`, { user: { id: userIpAndHeaders.userId }, userType: UserType.USER, ipAddress: userIpAndHeaders.ip, userAgent: userIpAndHeaders.headers["user-agent"], requestId: userIpAndHeaders.headers["x-request-id"], }, ); await queryRunner.commitTransaction(); return { message: AuthMessage.OTP_SENT, otpCode, }; } catch (error) { await queryRunner.rollbackTransaction(); throw error; } finally { await queryRunner.release(); } } //********************************** */ async approveInvoiceByUser(invoiceId: string, userIpAndHeaders: IUserIpAndHeaders, verifyOtpDto: VerifyOtpWithUserId) { const queryRunner = this.dataSource.createQueryRunner(); try { await queryRunner.connect(); await queryRunner.startTransaction(); const { code } = verifyOtpDto; const user = await this.usersService.findOneByIdWithQueryRunner(userIpAndHeaders.userId, queryRunner); const isValid = await this.otpService.verifyOtp(user.phone, code, "INVOICE_VERIFY"); if (!isValid) throw new BadRequestException(AuthMessage.INVALID_OTP); const invoice = await this.validateInvoiceForApproval(invoiceId, userIpAndHeaders.userId, queryRunner); invoice.status = InvoiceStatus.WAIT_PAYMENT; await queryRunner.manager.save(Invoice, invoice); await this.notificationQueue.addApprovedInvoiceNotification(userIpAndHeaders.userId, { invoiceId: invoice.numericId.toString(), dueDate: invoice.dueDate, createDate: invoice.createdAt, price: new Decimal(invoice.totalPrice).toNumber(), userPhone: user.phone, userEmail: user.email, items: invoice.items.map((item) => item.name).join(", "), }); await this.otpService.delOtpFormCache(user.phone, "INVOICE_VERIFY"); await this.accessLogService.logUserAction( OperationType.APPROVE, "Invoice", invoiceId, `user approved invoice by user: ${invoice.numericId}`, { user: { id: userIpAndHeaders.userId }, ipAddress: userIpAndHeaders.ip, userAgent: userIpAndHeaders.headers["user-agent"], requestId: userIpAndHeaders.headers["x-request-id"], userType: UserType.USER, }, ); await queryRunner.commitTransaction(); return { message: InvoiceMessage.APPROVED, invoice, }; } catch (error) { await queryRunner.rollbackTransaction(); throw error; } finally { await queryRunner.release(); } } //********************************** */ private async validateInvoiceForApproval(invoiceId: string, userId: string, queryRunner: QueryRunner): Promise { const invoice = await queryRunner.manager.findOne(Invoice, { where: { id: invoiceId, user: { id: userId } }, relations: { items: true }, }); if (!invoice) throw new BadRequestException(InvoiceMessage.NOT_FOUND_BY_ID); if (invoice.status === InvoiceStatus.WAIT_PAYMENT) throw new BadRequestException(InvoiceMessage.ALREADY_APPROVED); if (invoice.status === InvoiceStatus.PAID) throw new BadRequestException(InvoiceMessage.INVOICE_ALREADY_PAID); if (dayjs().isAfter(dayjs(invoice.dueDate).add(INVOICE.MAX_DAYS_AFTER_OVERDUE, "day"))) { invoice.status = InvoiceStatus.ARCHIVED; await queryRunner.manager.save(Invoice, invoice); throw new BadRequestException(InvoiceMessage.INVOICE_IS_OVERDUE); } if (invoice.status !== InvoiceStatus.PENDING) throw new BadRequestException(InvoiceMessage.INVOICE_CAN_NOT_APPROVED); return invoice; } ///********************************** */ async createInvoiceForSubscription(user: User, plan: SubscriptionPlan, userSub: UserSubscription, dueDate: Date, qryRnr: QueryRunner, purpose: InvoicePurpose) { const discount = plan.directDiscount; const originalPrice = plan.originalPrice || plan.price; const finalPrice = plan.price; const invoiceItem = { name: plan.service.name, count: 1, unitPrice: originalPrice, discount: discount ? new Decimal(originalPrice).sub(finalPrice).toNumber() : 0, subscriptionPlan: userSub, plan, totalPrice: finalPrice, }; const taxAmount = new Decimal(finalPrice).mul(0.1); const totalPrice = new Decimal(finalPrice).add(taxAmount); const invoice = qryRnr.manager.create(Invoice, { user, totalPrice: totalPrice, originalPrice: new Decimal(originalPrice).add(new Decimal(originalPrice).mul(0.1)), tax: taxAmount.toNumber(), status: InvoiceStatus.WAIT_PAYMENT, dueDate, items: [invoiceItem], discount: discount || undefined, purpose }); await qryRnr.manager.save(Invoice, invoice); await this.notificationQueue.addInvoiceCreationNotification(user.id, { invoiceId: invoice.numericId.toString(), dueDate: invoice.dueDate, createDate: invoice.createdAt, price: totalPrice.toNumber(), userPhone: user.phone, userEmail: user.email, items: plan.service.name, }); await this.invoiceQueue.add( INVOICE.REMINDER_JOB_NAME, { invoiceId: invoice.id }, { delay: dayjs(invoice.dueDate).subtract(INVOICE.DAYS_BEFORE_OVERDUE, "day").diff(dayjs()), attempts: INVOICE.REMINDER_JOB_ATTEMPTS, backoff: { type: "exponential", delay: INVOICE.REMINDER_JOB_BACKOFF }, priority: INVOICE.REMINDER_JOB_PRIORITY, }, ); return invoice; } //********************************** */ async createInvoiceForSubscriptionUpgrade(user: User, plan: SubscriptionPlan, userSub: UserSubscription, dueDate: Date, qryRnr: QueryRunner, upgradeCost: number) { const discount = plan.directDiscount; const invoiceItem = { name: `${plan.service.name} - Upgrade`, count: 1, unitPrice: upgradeCost, discount: 0, subscriptionPlan: userSub, plan: plan, totalPrice: upgradeCost, }; const taxAmount = new Decimal(upgradeCost).mul(0.1); const totalPrice = new Decimal(upgradeCost).add(taxAmount); const invoice = qryRnr.manager.create(Invoice, { user, totalPrice: totalPrice, originalPrice: new Decimal(upgradeCost).add(new Decimal(upgradeCost).mul(0.1)), tax: taxAmount.toNumber(), status: InvoiceStatus.WAIT_PAYMENT, dueDate, items: [invoiceItem], discount: discount || undefined, purpose: InvoicePurpose.UPGRADE }); await qryRnr.manager.save(Invoice, invoice); await this.notificationQueue.addInvoiceCreationNotification(user.id, { invoiceId: invoice.numericId.toString(), dueDate: invoice.dueDate, createDate: invoice.createdAt, price: totalPrice.toNumber(), userPhone: user.phone, userEmail: user.email, items: plan.service.name, }); await this.invoiceQueue.add( INVOICE.REMINDER_JOB_NAME, { invoiceId: invoice.id }, { delay: dayjs(invoice.dueDate).subtract(INVOICE.DAYS_BEFORE_OVERDUE, "day").diff(dayjs()), attempts: INVOICE.REMINDER_JOB_ATTEMPTS, backoff: { type: "exponential", delay: INVOICE.REMINDER_JOB_BACKOFF }, priority: INVOICE.REMINDER_JOB_PRIORITY, }, ); return invoice; } //********************************** */ async createInvoiceForSupportPlan( user: User, supPlan: SupportPlan, userSupPlan: UserSupportPlan, dueDate: Date, qryRnr: QueryRunner, price?: number, ) { const originalPrice = supPlan.price; const finalPrice = price || supPlan.price; const invoiceItem = { name: supPlan.name, count: 1, unitPrice: originalPrice, discount: 0, supportPlan: userSupPlan, totalPrice: finalPrice, }; const taxAmount = new Decimal(finalPrice).mul(0.1); const totalPrice = new Decimal(finalPrice).add(taxAmount); const invoice = qryRnr.manager.create(Invoice, { user, totalPrice: totalPrice, originalPrice: new Decimal(originalPrice).add(new Decimal(originalPrice).mul(0.1)), tax: taxAmount.toNumber(), status: InvoiceStatus.WAIT_PAYMENT, dueDate, items: [invoiceItem], }); await qryRnr.manager.save(Invoice, invoice); await this.notificationQueue.addInvoiceCreationNotification(user.id, { invoiceId: invoice.numericId.toString(), dueDate: invoice.dueDate, createDate: invoice.createdAt, price: totalPrice.toNumber(), userPhone: user.phone, userEmail: user.email, items: supPlan.name, }); await this.invoiceQueue.add( INVOICE.REMINDER_JOB_NAME, { invoiceId: invoice.id }, { delay: dayjs(invoice.dueDate).subtract(INVOICE.DAYS_BEFORE_OVERDUE, "day").diff(dayjs()), attempts: INVOICE.REMINDER_JOB_ATTEMPTS, backoff: { type: "exponential", delay: INVOICE.REMINDER_JOB_BACKOFF }, priority: INVOICE.REMINDER_JOB_PRIORITY, }, ); return invoice; } ///********************************** */ async getInvoices(queryDto: InvoicesSearchQueryDto) { const [invoices, count] = await this.invoiceRepository.getInvoicesForAdmin(queryDto); const usersThatHaveInvoices = await this.invoiceRepository .createQueryBuilder("invoice") .select( "DISTINCT ON (user.id) user.id as id, user.firstName as firstName, user.lastName as lastName, user.email as email, user.phone as phone", ) .leftJoin("invoice.user", "user") .getRawMany(); return { users: usersThatHaveInvoices, invoices, count, paginate: true, }; } //*********************************** */ async getInvoiceById(invoiceId: string, isAdmin: boolean, userId: string) { let invoice: Invoice | null; if (isAdmin) { invoice = await this.invoiceRepository.findOne({ where: { id: invoiceId }, relations: { items: { subscriptionPlan: true }, user: true, discount: true }, }); } else { invoice = await this.invoiceRepository.findOne({ where: { id: invoiceId, user: { id: userId } }, relations: { items: { subscriptionPlan: true }, discount: true }, }); } if (!invoice) throw new BadRequestException(InvoiceMessage.NOT_FOUND_BY_ID); let shouldCharge = false; let remainingToCharge = 0; if (!isAdmin) { if (invoice.status === InvoiceStatus.WAIT_PAYMENT) { const queryRunner = this.dataSource.createQueryRunner(); try { await queryRunner.connect(); const userWallet = await this.walletsService.getWalletByUserId(userId, queryRunner); const invoicePrice = new Decimal(invoice.totalPrice); const walletBalance = new Decimal(userWallet.balance); if (walletBalance.lessThan(invoicePrice)) { remainingToCharge = invoicePrice.sub(walletBalance).round().toNumber(); shouldCharge = true; } else { remainingToCharge = 0; } } finally { await queryRunner.release(); } } return { invoice, shouldCharge, remainingToCharge, }; } return { invoice, }; } //*********************************** */ async getUserInvoices(queryDto: UserInvoicesSearchQueryDto, userId: string) { const { limit, skip } = PaginationUtils(queryDto); const queryBuilder = this.invoiceRepository.createQueryBuilder("invoice"); queryBuilder .andWhere("invoice.user.id = :userId", { userId: userId }) .leftJoinAndSelect("invoice.items", "items") .leftJoinAndSelect("items.subscriptionPlan", "subscriptionPlan", "subscriptionPlan.id IS NOT NULL") .leftJoinAndSelect("subscriptionPlan.plan", "plan", "subscriptionPlan.id IS NOT NULL") .leftJoin("plan.service", "service", "subscriptionPlan.id IS NOT NULL") .addSelect(["service.name", "service.id"]); if (queryDto.status) { queryBuilder.andWhere("invoice.status = :status", { status: queryDto.status }); } queryBuilder.orderBy("invoice.createdAt", "DESC").skip(skip).take(limit); const [invoices, count] = await queryBuilder.getManyAndCount(); return { invoices, count, paginate: true, }; } //*********************************** */ async payInvoice( invoiceId: string, userId: string, userIpAndHeaders?: IUserIpAndHeaders, queryRunner = this.dataSource.createQueryRunner(), ) { let transactionStarted = false; try { if (!queryRunner.isTransactionActive) { await queryRunner.connect(); await queryRunner.startTransaction(); transactionStarted = true; } const user = await this.usersService.findOneByIdWithQueryRunner(userId, queryRunner); const invoice = await this.getPendingOrOverdueInvoiceByIdWithQueryRunner(invoiceId, user.id, queryRunner); if (invoice.status !== InvoiceStatus.WAIT_PAYMENT && invoice.status !== InvoiceStatus.OVERDUE) throw new BadRequestException(InvoiceMessage.INVOICE_CAN_NOT_PAID); const userWallet = await this.walletsService.getWalletByUserId(user.id, queryRunner); if (userWallet.balance < invoice.totalPrice) throw new BadRequestException(WalletMessage.INSUFFICIENT_BALANCE); userWallet.balance = new Decimal(userWallet.balance).sub(invoice.totalPrice); await queryRunner.manager.save(Wallet, userWallet); if (invoice.items[0]?.subscriptionPlan && !invoice.isExternal) { const userSubscription = invoice.items[0].subscriptionPlan; const invoiceItem = invoice.items[0]; const purpose = invoice.purpose if (purpose == InvoicePurpose.UPGRADE) { // This is an upgrade - update subscription with the new plan const upgradePlan = invoiceItem.plan!; const diffrence = upgradePlan.duration - userSubscription.plan.duration if (diffrence > 0) { userSubscription.endDate = dayjs(userSubscription.endDate).add(diffrence, "day").toDate(); } this.logger.log(`Subscription ${userSubscription.id} upgraded to plan ${upgradePlan.name}`); userSubscription.plan = upgradePlan; await this.scheduleNextRenewalJob(userSubscription); // Call external API for dmenu service upgrades if (upgradePlan.service.slug?.toLowerCase().includes('dmenu')) { try { this.logger.log(`Calling external API for dmenu upgrade: subscription ${userSubscription.id}`); const dmenuPlan = upgradePlan.name.includes("دلیوری") ? "premium" : "base"; await this.restaurantService.upgradeSubscription( userSubscription.id, dmenuPlan, userSubscription.endDate ); this.logger.log(`External API call completed for dmenu upgrade: subscription ${userSubscription.id}`); } catch (error) { this.logger.error(`Failed to call external API for dmenu upgrade: ${error instanceof Error ? error.message : 'Unknown error'}`); // Don't fail the transaction if external API call fails } } else if (upgradePlan.service.slug?.toLowerCase().includes('dkala')) { try { this.logger.log(`Calling external API for dkala upgrade: subscription ${userSubscription.id}`); await this.dkalaService.upgradeSubscription( userSubscription.id, userSubscription.endDate ); this.logger.log(`External API call completed for dkala upgrade: subscription ${userSubscription.id}`); } catch (error) { this.logger.error(`Failed to call external API for dkala upgrade: ${error instanceof Error ? error.message : 'Unknown error'}`); // Don't fail the transaction if external API call fails } } } else if (purpose == InvoicePurpose.RENEW) { if (userSubscription.status == SubscriptionStatus.ACTIVE) { userSubscription.endDate = dayjs(userSubscription.endDate).add(userSubscription.plan.duration, "day").toDate(); } else { userSubscription.endDate = dayjs().add(userSubscription.plan.duration, "day").toDate(); } if (invoiceItem.plan) { userSubscription.plan = invoiceItem.plan } this.logger.log(`Subscription ${userSubscription.id} renewed until ${userSubscription.endDate}`); await this.scheduleNextRenewalJob(userSubscription); // Call external API for dmenu service upgrades if (userSubscription.plan.service.slug?.toLowerCase().includes('dmenu')) { try { this.logger.log(`Calling external API for dmenu upgrade: subscription ${userSubscription.id}`); const dmenuPlan = userSubscription.plan.name.includes("دلیوری") ? "premium" : "base"; await this.restaurantService.upgradeSubscription( userSubscription.id, dmenuPlan, userSubscription.endDate ); this.logger.log(`External API call completed for dmenu upgrade: subscription ${userSubscription.id}`); } catch (error) { this.logger.error(`Failed to call external API for dmenu upgrade: ${error instanceof Error ? error.message : 'Unknown error'}`); // Don't fail the transaction if external API call fails } } else if (userSubscription.plan.service.slug?.toLowerCase().includes('dkala')) { try { this.logger.log(`Calling external API for dkala renew: subscription ${userSubscription.id}`); await this.dkalaService.upgradeSubscription( userSubscription.id, userSubscription.endDate ); this.logger.log(`External API call completed for dkala renew: subscription ${userSubscription.id}`); } catch (error) { this.logger.error(`Failed to call external API for dkala renew: ${error instanceof Error ? error.message : 'Unknown error'}`); // Don't fail the transaction if external API call fails } } } else if (purpose == InvoicePurpose.NEW) { console.log('hi-1') if (userSubscription.plan.service.slug?.toLowerCase().includes('dpage')) { try { this.logger.log(`Calling external API for dpage setup: subscription ${userSubscription.id}`); console.log('userSubscription.plan.count', userSubscription.plan.count) await this.dpageService.createDpageAccount({ danakSubscriptionId: userSubscription.id, name: userSubscription.businessName, slug: randomInt(100_000, 1_000_000).toString(), phone: user.phone, firstName: user.firstName, lastName: user.lastName, maxCataloguesCount: userSubscription.plan.count ?? 0 }); this.logger.log(`External API call completed for dpage setup account: subscription ${userSubscription.id}`); } catch (error) { this.logger.error(`Failed to call external API for dpage setup account: ${error instanceof Error ? error.message : 'Unknown error'}`); // Don't fail the transaction if external API call fails } } userSubscription.status = SubscriptionStatus.ACTIVE; await this.scheduleNextRenewalJob(userSubscription); this.logger.log(`Subscription ${userSubscription.id} activated `); } await queryRunner.manager.save(UserSubscription, userSubscription); await this.walletsService.createSubscriptionTransaction(invoice.totalPrice, userWallet.id, queryRunner); await this.addNotifyForWalletDeduction(invoice, user, userWallet, WalletMessage.SUBSCRIPTION_WALLET_TRANSFER); await this.addNotifyAdminForSubscriptionPaymentToQueue(invoice); // } else if (invoice.items[0]?.supportPlan && !invoice.isExternal) { const oldUserSupportPlan = await queryRunner.manager.findOne(UserSupportPlan, { where: { user: { id: user.id }, status: UserSupportPlanStatus.ACTIVE }, }); if (oldUserSupportPlan) { await queryRunner.manager.update(UserSupportPlan, oldUserSupportPlan.id, { status: UserSupportPlanStatus.INACTIVE }); } const userSupportPlan = invoice.items[0].supportPlan; userSupportPlan.status = UserSupportPlanStatus.ACTIVE; await queryRunner.manager.save(UserSupportPlan, userSupportPlan); await this.walletsService.createSupportPlanTransaction(invoice.totalPrice, userWallet.id, queryRunner); await this.addNotifyForWalletDeduction(invoice, user, userWallet, WalletMessage.SUPPORT_PLAN_WALLET_TRANSFER); // } else if (invoice.isExternal) { if (invoice.items[0]?.name == 'purchase_design') { this.dpageService.finishPurchaseDesign(invoice.externalBusinessId!, invoice.id) } else if (invoice.items[0]?.name == 'purchase_new_catalogue') { // call dpage api const businessId = invoice.externalBusinessId if (!businessId) { throw new BadRequestException("business id not found") } const count = invoice.items[0].count this.dpageService.finishPurchaseCatalogue(businessId, count) } } else { await this.walletsService.createInvoiceTransaction(invoice.totalPrice, userWallet.id, queryRunner); await this.addNotifyForWalletDeduction(invoice, user, userWallet, WalletMessage.INVOICE_WALLET_TRANSFER); } invoice.status = InvoiceStatus.PAID; invoice.paidAt = dayjs().toDate(); await queryRunner.manager.save(Invoice, invoice); if (invoice.isExternal && invoice.items?.[0]?.subscriptionPlan) { const externalInvoiceJobData: IExternalInvoiceJob = { invoice, items: invoice.items, businessId: invoice.externalBusinessId!, danakSubscriptionId: invoice.items[0].subscriptionPlan.id, timestamp: dayjs().toISOString(), }; await this.externalInvoiceQueue.add(INVOICE.EXTERNAL_CALLBACK_JOB_NAME, externalInvoiceJobData, { // delay: dayjs().add(10000, "millisecond").diff(dayjs()), delay: 5000, attempts: INVOICE.EXTERNAL_JOB_ATTEMPTS, backoff: { type: "exponential", delay: INVOICE.EXTERNAL_JOB_BACKOFF }, priority: INVOICE.EXTERNAL_JOB_PRIORITY, }); } await this.notificationQueue.addBillInvoiceNotification(userId, { invoiceId: invoice.numericId.toString(), dueDate: invoice.dueDate, createDate: invoice.createdAt, price: new Decimal(invoice.totalPrice).toNumber(), userPhone: user.phone, userEmail: user.email, items: invoice.items.map((item) => item.name).join(", "), paidAt: invoice.paidAt, }); if (userIpAndHeaders) { await this.accessLogService.logUserAction(OperationType.UPDATE, "Invoice", invoiceId, `user paid invoice: ${invoice.numericId}`, { user: { id: userIpAndHeaders.userId }, ipAddress: userIpAndHeaders.ip, userAgent: userIpAndHeaders.headers["user-agent"], requestId: userIpAndHeaders.headers["x-request-id"], userType: UserType.USER, }); } if (transactionStarted) await queryRunner.commitTransaction(); return { message: InvoiceMessage.INVOICE_PAID, invoice, }; } catch (error) { if (transactionStarted) await queryRunner.rollbackTransaction(); throw error; } finally { if (transactionStarted) await queryRunner.release(); } } //*********************************** */ async getPendingOrOverdueInvoiceByIdWithQueryRunner(invoiceId: string, userId: string, queryRunner: QueryRunner) { const invoice = await queryRunner.manager.findOne(Invoice, { where: { user: { id: userId }, status: In([InvoiceStatus.WAIT_PAYMENT, InvoiceStatus.OVERDUE]), id: invoiceId, }, relations: { items: { subscriptionPlan: { plan: { service: true } }, plan: { service: true }, supportPlan: { supportPlan: true, }, }, }, }); if (!invoice) throw new BadRequestException(InvoiceMessage.NOT_FOUND_BY_ID); return invoice; } //*********************************** */ async getInvoicesCount() { const count = await this.invoiceRepository.count({ where: { createdAt: Between(new Date(new Date().setHours(0, 0, 0, 0)), new Date(new Date().setHours(23, 59, 59, 999))), }, }); return count; } //*********************************** */ async countUserInvoices(userId: string) { const invoiceCount = await this.invoiceRepository.count({ where: { user: { id: userId }, status: InvoiceStatus.WAIT_PAYMENT, }, }); return invoiceCount; } //*********************************** */ async applyDiscount(invoiceId: string, discountCode: string, userId: string) { const queryRunner = this.dataSource.createQueryRunner(); try { await queryRunner.connect(); await queryRunner.startTransaction(); const invoice = await this.getInvoiceByIdWithQueryRunner(invoiceId, userId, queryRunner); if (invoice.discount) throw new BadRequestException(InvoiceMessage.DISCOUNT_ALREADY_APPLIED); const discount = await queryRunner.manager.findOne(Discount, { where: { code: discountCode, isActive: true }, relations: { user: true }, }); if (!discount) throw new BadRequestException(InvoiceMessage.INVALID_DISCOUNT_CODE); if (discount?.user?.id && discount?.user?.id !== userId) throw new BadRequestException(InvoiceMessage.DISCOUNT_NOT_FOR_THIS_USER); if (!discount?.isActive) throw new BadRequestException(InvoiceMessage.DISCOUNT_IS_NOT_ACTIVE); if (discount.endDate && dayjs(discount.endDate).isBefore(dayjs())) throw new BadRequestException(InvoiceMessage.DISCOUNT_EXPIRED); if (discount.startDate && dayjs(discount.startDate).isAfter(dayjs())) throw new BadRequestException(InvoiceMessage.DISCOUNT_NOT_STARTED); if (!invoice.originalPrice) invoice.originalPrice = new Decimal(invoice.totalPrice); let discountAmount: Decimal; if (discount.type === DiscountType.PERCENTAGE) { discountAmount = new Decimal(invoice.originalPrice).mul(discount.value).div(100); } else { discountAmount = new Decimal(discount.value); } invoice.totalPrice = new Decimal(invoice.originalPrice).sub(discountAmount); invoice.discount = discount; await queryRunner.manager.save(invoice); await queryRunner.commitTransaction(); return { message: InvoiceMessage.DISCOUNT_APPLIED, invoice, }; } catch (error) { await queryRunner.rollbackTransaction(); throw error; } finally { await queryRunner.release(); } } //*********************************** */ async cancelDiscount(invoiceId: string, userId: string) { const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { const invoice = await this.getInvoiceByIdWithQueryRunner(invoiceId, userId, queryRunner); if (!invoice.discount) throw new BadRequestException(InvoiceMessage.NO_DISCOUNT); if (!invoice.originalPrice) throw new BadRequestException(InvoiceMessage.ORIGINAL_PRICE_NOT_FOUND); invoice.totalPrice = invoice.originalPrice; invoice.discount = null; await queryRunner.manager.save(this.invoiceRepository.target, invoice); await queryRunner.commitTransaction(); return { message: InvoiceMessage.DISCOUNT_CANCELED, invoice, }; } catch (error) { await queryRunner.rollbackTransaction(); throw error; } finally { await queryRunner.release(); } } //*********************************** */ async createInvoiceForExternalService(createDto: CreateExternalInvoiceDto) { const queryRunner = this.dataSource.createQueryRunner(); try { await queryRunner.connect(); await queryRunner.startTransaction(); const subscription = await queryRunner.manager.findOne(UserSubscription, { where: { id: createDto.danakSubscriptionId }, relations: { user: true }, }); if (!subscription) throw new BadRequestException(InvoiceMessage.DANAK_SUBSCRIPTION_NOT_FOUND); const user = subscription.user; this.validateInvoiceItems(createDto.items); this.validateRecurringInvoice(createDto); const invoiceItems = createDto.items.map((item) => ({ name: item.name, count: item.count, unitPrice: item.unitPrice, discount: item.discount || 0, totalPrice: item.unitPrice * item.count - (item.unitPrice * item.count * item.discount) / 100, subscriptionPlan: subscription, })); const totalPrice = invoiceItems.reduce((sum, item) => new Decimal(item.totalPrice).add(sum), new Decimal(0)); const tax = totalPrice.mul(0.1); if (totalPrice.lessThanOrEqualTo(0)) throw new BadRequestException(InvoiceMessage.TOTAL_PRICE_MUST_BE_POSITIVE); const dueDate = dayjs().add(INVOICE.EXTERNAL_INVOICE_DUEDATE, "day").toDate(); const invoice = queryRunner.manager.create(this.invoiceRepository.target, { user, totalPrice: totalPrice.add(tax).toNumber(), originalPrice: totalPrice.add(tax).toNumber(), items: invoiceItems, tax: tax.toNumber(), dueDate, status: InvoiceStatus.WAIT_PAYMENT, isRecurring: createDto.isRecurring, recurringPeriod: createDto.recurringPeriod, maxRecurringCycles: createDto.maxRecurringCycles, currentRecurringCycle: 0, isExternal: true, externalBusinessId: createDto.businessId, }); await queryRunner.manager.save(this.invoiceRepository.target, invoice); await this.notificationQueue.addInvoiceCreationNotification(user.id, { invoiceId: invoice.numericId.toString(), dueDate: invoice.dueDate, createDate: invoice.createdAt, price: new Decimal(invoice.totalPrice).toNumber(), userPhone: user.phone, userEmail: user.email, items: invoiceItems.map((item) => item.name).join(", "), paidAt: invoice.paidAt, }); await this.scheduleInvoiceJobs(invoice, createDto); await queryRunner.commitTransaction(); return { message: InvoiceMessage.CREATED, invoice, }; } catch (error) { await queryRunner.rollbackTransaction(); this.logger.error( `Failed to create invoice: ${error instanceof Error ? error.message : "Unknown error"}`, error instanceof Error ? error.stack : "", ); throw error; } finally { await queryRunner.release(); } } //*********************************** */ private async getInvoiceByIdWithQueryRunner(invoiceId: string, userId: string, queryRunner: QueryRunner) { const invoice = await queryRunner.manager.findOne(Invoice, { where: { id: invoiceId, user: { id: userId } }, relations: { user: true, discount: true }, }); if (!invoice) throw new BadRequestException(InvoiceMessage.NOT_FOUND_BY_ID_OR_NOT_BELONG_TO_USER); return invoice; } //*********************************** */ private async scheduleInvoiceJobs(invoice: Invoice, createDto?: CreateInvoiceDto | UpdateInvoiceDto) { // Add to queue for billing reminder await this.invoiceQueue.add( INVOICE.REMINDER_JOB_NAME, { invoiceId: invoice.id }, { delay: dayjs(invoice.dueDate).subtract(INVOICE.DAYS_BEFORE_OVERDUE, "day").diff(dayjs()), attempts: INVOICE.REMINDER_JOB_ATTEMPTS, backoff: { type: "exponential", delay: INVOICE.REMINDER_JOB_BACKOFF }, priority: INVOICE.REMINDER_JOB_PRIORITY, }, ); if (createDto?.isRecurring && !createDto?.recurringPeriod) throw new BadRequestException(InvoiceMessage.RECURRING_PERIOD_REQUIRED); // Add to queue for recurring invoice if applicable if (createDto?.isRecurring && createDto?.recurringPeriod) { await this.invoiceQueue.add( INVOICE.RECURRING_JOB_NAME, { invoiceId: invoice.id, adminCreated: true, }, { delay: await this.calculateRecurringDelay(createDto.recurringPeriod), attempts: INVOICE.RECURRING_JOB_ATTEMPTS, backoff: { type: "exponential", delay: INVOICE.RECURRING_JOB_BACKOFF }, priority: INVOICE.RECURRING_JOB_PRIORITY, }, ); this.logger.log(`Scheduled recurring invoice for user ${createDto.userId} with interval ${createDto.recurringPeriod}`); } } //*********************************** */ private async updateScheduleInvoiceJobs(invoice: Invoice, updateDto: UpdateInvoiceDto) { // await this.removeRecurringInvoiceJobs(invoice.id); await this.removeReminderInvoiceJobs(invoice.id); await this.removeAdminNotificationJobs(invoice.id); if (updateDto.isRecurring) { if (!updateDto.recurringPeriod) throw new BadRequestException(InvoiceMessage.RECURRING_PERIOD_REQUIRED); if (updateDto.maxRecurringCycles && updateDto.maxRecurringCycles < 1) { throw new BadRequestException(InvoiceMessage.MAX_RECURRING_CYCLES_MUST_BE_POSITIVE); } await this.scheduleInvoiceJobs(invoice, updateDto); this.logger.log(`Updated recurring invoice for user ${updateDto.userId} with interval ${updateDto.recurringPeriod}`); } } //*********************************** */ private async addNotifyAdminForSubscriptionPaymentToQueue(invoice: Invoice) { await this.invoiceQueue.add( INVOICE.SUBSCRIPTION_ADMIN_NOTIFICATION_JOB_NAME, { invoiceId: invoice.id }, { delay: INVOICE.SUBSCRIPTION_ADMIN_NOTIFICATION_JOB_DELAY, attempts: INVOICE.SUBSCRIPTION_ADMIN_NOTIFICATION_JOB_ATTEMPTS, backoff: { type: "exponential", delay: INVOICE.SUBSCRIPTION_ADMIN_NOTIFICATION_JOB_BACKOFF }, priority: INVOICE.SUBSCRIPTION_ADMIN_NOTIFICATION_JOB_PRIORITY, }, ); } //*********************************** */ private async addNotifyForWalletDeduction(invoice: Invoice, user: User, userWallet: Wallet, reason: string) { await this.notificationQueue.addWalletDeductionNotification(user.id, { amount: new Decimal(invoice.totalPrice).toNumber(), balance: new Decimal(userWallet.balance).toNumber(), reason, date: dayjs().toDate(), userPhone: user.phone, userEmail: user.email, }); } //*********************************** */ private async removeRecurringInvoiceJobs(invoiceId: string) { const existingJobs = await this.invoiceQueue.getJobs(["delayed", "waiting", "active"]); let removedCount = 0; for (const job of existingJobs) { if (job.name === INVOICE.RECURRING_JOB_NAME && job.data?.invoiceId === invoiceId) { await job.remove(); removedCount++; } } if (removedCount > 0) { this.logger.log(`Removed ${removedCount} recurring invoice jobs for invoice: ${invoiceId}`); } } //*********************************** */ private async removeReminderInvoiceJobs(invoiceId: string) { const existingJobs = await this.invoiceQueue.getJobs(["delayed", "waiting", "active"]); let removedCount = 0; for (const job of existingJobs) { if (job.name === INVOICE.REMINDER_JOB_NAME && job.data?.invoiceId === invoiceId) { await job.remove(); removedCount++; } } if (removedCount > 0) { this.logger.log(`Removed ${removedCount} reminder invoice jobs for invoice: ${invoiceId}`); } } //*********************************** */ private async removeAdminNotificationJobs(invoiceId: string) { const existingJobs = await this.invoiceQueue.getJobs(["delayed", "waiting", "active"]); let removedCount = 0; for (const job of existingJobs) { if (job.name === INVOICE.SUBSCRIPTION_ADMIN_NOTIFICATION_JOB_NAME && job.data?.invoiceId === invoiceId) { await job.remove(); removedCount++; } } if (removedCount > 0) { this.logger.log(`Removed ${removedCount} admin notification jobs for invoice: ${invoiceId}`); } } ///********************************** */ private validateInvoiceItems(items: InvoiceItemDto[]): void { if (!items || items.length === 0) throw new BadRequestException(InvoiceMessage.ITEMS_REQUIRED); items.forEach((item) => { if (item.unitPrice <= 0) throw new BadRequestException(InvoiceMessage.UNIT_PRICE_MUST_BE_POSITIVE); if (item.count <= 0) throw new BadRequestException(InvoiceMessage.COUNT_MUST_BE_POSITIVE); if (item.discount && (item.discount < 0 || item.discount > 100)) { throw new BadRequestException(InvoiceMessage.DISCOUNT_MUST_BE_BETWEEN_0_AND_100); } }); } ///********************************** */ private validateRecurringInvoice(createDto: CreateInvoiceDto | UpdateInvoiceDto) { if (createDto.isRecurring) { if (!createDto.recurringPeriod) throw new BadRequestException(InvoiceMessage.RECURRING_PERIOD_REQUIRED); if (createDto.maxRecurringCycles && createDto.maxRecurringCycles < 1) { throw new BadRequestException(InvoiceMessage.MAX_RECURRING_CYCLES_MUST_BE_POSITIVE); } return true; } return false; } //********************************** */ private async calculateRecurringDelay(recurringPeriod: RecurringPeriodEnum) { let delayMs: number; switch (recurringPeriod) { case RecurringPeriodEnum.WEEKLY: delayMs = dayjs().add(1, "week").subtract(INVOICE.DAYS_BEFORE_OVERDUE, "day").diff(dayjs()); break; case RecurringPeriodEnum.MONTHLY: delayMs = dayjs().add(1, "month").subtract(7, "day").diff(dayjs()); break; case RecurringPeriodEnum.QUARTERLY: delayMs = dayjs().add(3, "month").subtract(7, "day").diff(dayjs()); break; case RecurringPeriodEnum.SEMIANNUALLY: delayMs = dayjs().add(6, "month").subtract(7, "day").diff(dayjs()); break; case RecurringPeriodEnum.ANNUALLY: delayMs = dayjs().add(1, "year").subtract(7, "day").diff(dayjs()); break; default: delayMs = dayjs().add(1, "month").subtract(7, "day").diff(dayjs()); } this.logger.log(`Calculated recurring delay: ${delayMs}ms for period: ${recurringPeriod}`); return delayMs; } //*********************************** */ private async scheduleNextRenewalJob(userSubscription: UserSubscription) { if (userSubscription.plan.isFree) { this.logger.debug(`Skipping renewal job for free plan subscription ${userSubscription.id}`); return; } const renewalJobDate = dayjs(userSubscription.endDate).subtract(INVOICE.SUBSCRIPTION_RENEWAL_DAYS_BEFORE_EXPIRY, "day"); const delay = renewalJobDate.diff(dayjs()); if (delay <= 0) { this.logger.warn(`Subscription ${userSubscription.id} expires too soon to schedule renewal job`); return; } this.logger.debug( `Scheduling renewal job for subscription ${userSubscription.id} to run on ${renewalJobDate.format("YYYY-MM-DD HH:mm:ss")}`, ); await this.invoiceQueue.add( INVOICE.SUBSCRIPTION_RENEWAL_JOB_NAME, { userSubscriptionId: userSubscription.id, }, { delay, attempts: INVOICE.SUBSCRIPTION_RENEWAL_JOB_ATTEMPTS, backoff: { type: "exponential", delay: INVOICE.SUBSCRIPTION_RENEWAL_JOB_BACKOFF }, priority: INVOICE.SUBSCRIPTION_RENEWAL_JOB_PRIORITY, }, ); } //*********************************** */ async findInvoicesByCustomerIds(custmerIds: string[], query: SearchResellerInvoicesQueryDto) { return this.invoiceRepository.findPaginatedListByUserIds(query, custmerIds) } }