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, QueryRunner } from "typeorm"; import { AuthMessage, InvoiceMessage, WalletMessage } from "../../../common/enums/message.enum"; import { VerifyOtpWithUserId } from "../../auth/DTO/verify-otp.dto"; import { Discount } from "../../discounts/entities/discount.entity"; import { DiscountType } from "../../discounts/enums/discount-type.enum"; import { NotificationsService } from "../../notifications/providers/notifications.service"; import { SubscriptionPlan } from "../../subscriptions/entities/subscription.entity"; import { UserSubscription } from "../../subscriptions/entities/user-subscription.entity"; import { SubscriptionStatus } from "../../subscriptions/enums/subscription-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 { 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 { InvoiceItemsRepository } from "../repositories/invoice-items.repository"; import { InvoicesRepository } from "../repositories/invoices.repository"; @Injectable() export class InvoicesService { private readonly logger = new Logger(InvoicesService.name); constructor( @InjectQueue(INVOICE.QUEUE_NAME) private readonly invoiceQueue: Queue, private readonly notificationsService: NotificationsService, 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 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): void { 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); } } } ///********************************** */ async createInvoiceAdmin(createDto: CreateInvoiceDto) { 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.notificationsService.createInvoiceCreationNotification( 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, }, queryRunner, ); 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(); } } //********************************** */ async updateInvoiceAdmin(invoiceId: string, updateDto: UpdateInvoiceDto) { 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) { 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)); 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; } // Save the updated invoice await queryRunner.manager.save(this.invoiceRepository.target, invoice); await queryRunner.commitTransaction(); return { message: InvoiceMessage.INVOICE_UPDATED, invoice, }; } catch (error) { await queryRunner.rollbackTransaction(); throw error; } finally { await queryRunner.release(); } } //********************************** */ async approveInvoiceRequest(invoiceId: string, userId: string) { const queryRunner = this.dataSource.createQueryRunner(); try { await queryRunner.connect(); await queryRunner.startTransaction(); const user = await this.usersService.findOneByIdWithQueryRunner(userId, queryRunner); const invoice = await this.validateInvoiceForApproval(invoiceId, 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 queryRunner.commitTransaction(); return { message: AuthMessage.OTP_SENT, otpCode, }; } catch (error) { await queryRunner.rollbackTransaction(); throw error; } finally { await queryRunner.release(); } } //********************************** */ async approveInvoiceByUser(invoiceId: string, userId: string, verifyOtpDto: VerifyOtpWithUserId) { const queryRunner = this.dataSource.createQueryRunner(); try { await queryRunner.connect(); await queryRunner.startTransaction(); const { code } = verifyOtpDto; const user = await this.usersService.findOneByIdWithQueryRunner(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, userId, queryRunner); invoice.status = InvoiceStatus.WAIT_PAYMENT; await queryRunner.manager.save(Invoice, invoice); await this.notificationsService.createApprovedInvoiceNotification( 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(", "), }, queryRunner, ); await this.otpService.delOtpFormCache(user.phone, "INVOICE_VERIFY"); 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.EXPIRED; 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) { 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, 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, }); await qryRnr.manager.save(Invoice, invoice); await this.notificationsService.createInvoiceCreationNotification( 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, }, qryRnr, ); 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); 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) { const queryRunner = this.dataSource.createQueryRunner(); try { await queryRunner.connect(); await queryRunner.startTransaction(); const user = await this.usersService.findOneByIdWithQueryRunner(userId, queryRunner); const invoice = await this.getPendingInvoiceByIdWithQueryRunner(invoiceId, user.id, queryRunner); if (invoice.status !== InvoiceStatus.WAIT_PAYMENT) 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) { const userSubscription = invoice.items[0].subscriptionPlan; userSubscription.status = SubscriptionStatus.ACTIVE; // await queryRunner.manager.save(UserSubscription, userSubscription); await this.walletsService.createSubscriptionTransaction(invoice.totalPrice, userWallet.id, queryRunner); await this.addNotifyAdminForSubscriptionPaymentToQueue(invoice); // } else { await this.walletsService.createInvoiceTransaction(invoice.totalPrice, userWallet.id, queryRunner); } invoice.status = InvoiceStatus.PAID; invoice.paidAt = dayjs().toDate(); await queryRunner.manager.save(Invoice, invoice); await this.notificationsService.createBillInvoiceNotification( 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, }, queryRunner, ); await queryRunner.commitTransaction(); return { message: InvoiceMessage.INVOICE_PAID, invoice, }; } catch (error) { await queryRunner.rollbackTransaction(); throw error; } finally { await queryRunner.release(); } } //*********************************** */ async getPendingInvoiceByIdWithQueryRunner(invoiceId: string, userId: string, queryRunner: QueryRunner) { const invoice = await queryRunner.manager.findOne(Invoice, { where: { user: { id: userId }, status: InvoiceStatus.WAIT_PAYMENT, id: invoiceId, }, relations: { items: { subscriptionPlan: { plan: 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; } //********************************** */ 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.BIANNUALLY: 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; } //*********************************** */ 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 }, }); if (!discount) throw new BadRequestException(InvoiceMessage.INVALID_DISCOUNT_CODE); if (discount.endDate && dayjs(discount.endDate).isBefore(dayjs())) throw new BadRequestException(InvoiceMessage.DISCOUNT_EXPIRED); 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(); } } //*********************************** */ 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) { // 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 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, }, ); } }