From 10a7653451a2a15e0527c853d3872a3a7fc7b0e9 Mon Sep 17 00:00:00 2001 From: mahyargdz Date: Wed, 23 Apr 2025 14:29:48 +0330 Subject: [PATCH] update: the invoice service --- src/common/enums/message.enum.ts | 6 +++ src/configs/typeorm.config.ts | 2 +- .../invoices/DTO/create-invoice.dto.ts | 5 ++ .../invoices/entities/invoice.entity.ts | 13 +++++ .../invoices/providers/invoices.service.ts | 50 +++++++++++++++++-- .../invoices/queue/invoice.processor.ts | 37 +++++++++++++- 6 files changed, 107 insertions(+), 6 deletions(-) diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 4eb1b2f..8091317 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -486,6 +486,12 @@ export const enum InvoiceMessage { NOT_FOUND_BY_ID_OR_NOT_BELONG_TO_USER = "صورت حساب یافت نشد یا متعلق به کاربر نمی باشد", DISCOUNT_ALREADY_APPLIED = "ت`خفیف قبلا برای این صورت حساب اعمال شده است", DISCOUNT_EXPIRED = "تخفیف منقضی شده است", + MAX_RECURRING_CYCLES_MUST_BE_POSITIVE = "تعداد تکرار باید بزرگتر از ۰ باشد", + ITEMS_REQUIRED = "آیتم‌ها مورد نیاز است", + UNIT_PRICE_MUST_BE_POSITIVE = "قیمت واحد باید مثبت باشد", + COUNT_MUST_BE_POSITIVE = "تعداد باید مثبت باشد", + DISCOUNT_MUST_BE_BETWEEN_0_AND_100 = "تخفیف باید بین ۰ تا ۱۰۰ باشد", + TOTAL_PRICE_MUST_BE_POSITIVE = "قیمت کل باید مثبت باشد", } export const enum LearningMessage { diff --git a/src/configs/typeorm.config.ts b/src/configs/typeorm.config.ts index 9391ab2..8d8378f 100755 --- a/src/configs/typeorm.config.ts +++ b/src/configs/typeorm.config.ts @@ -13,7 +13,7 @@ export function databaseConfigs(): TypeOrmModuleAsyncOptions { username: configService.getOrThrow("DB_USER"), password: configService.getOrThrow("DB_PASS"), autoLoadEntities: true, - synchronize: configService.getOrThrow("NODE_ENV") == "production" ? false : false, + synchronize: configService.getOrThrow("NODE_ENV") == "production" ? false : true, logging: configService.getOrThrow("NODE_ENV") == "production" ? false : true, migrationsTableName: "typeorm_migrations", migrationsRun: false, diff --git a/src/modules/invoices/DTO/create-invoice.dto.ts b/src/modules/invoices/DTO/create-invoice.dto.ts index c3a3621..d951433 100755 --- a/src/modules/invoices/DTO/create-invoice.dto.ts +++ b/src/modules/invoices/DTO/create-invoice.dto.ts @@ -59,4 +59,9 @@ export class CreateInvoiceDto { enum: RecurringPeriodEnum, }) recurringPeriod?: RecurringPeriodEnum; + + @IsOptional() + @IsInt({ message: InvoiceMessage.MAX_RECURRING_CYCLES_MUST_BE_POSITIVE }) + @ApiPropertyOptional({ description: "Maximum number of recurring cycles (0 for unlimited)", example: 12, default: 0 }) + maxRecurringCycles?: number; } diff --git a/src/modules/invoices/entities/invoice.entity.ts b/src/modules/invoices/entities/invoice.entity.ts index ff80bf4..72b1d3f 100755 --- a/src/modules/invoices/entities/invoice.entity.ts +++ b/src/modules/invoices/entities/invoice.entity.ts @@ -6,6 +6,7 @@ import { BaseEntity } from "../../../common/entities/base.entity"; import { DecimalTransformer } from "../../../common/transformers/decimal.transformer"; import { Discount } from "../../discounts/entities/discount.entity"; import { User } from "../../users/entities/user.entity"; +import { RecurringPeriodEnum } from "../enums/invoice-recurring-period.enum"; import { InvoiceStatus } from "../enums/invoice-status.enum"; @Entity() @@ -37,6 +38,18 @@ export class Invoice extends BaseEntity { @Column({ type: "decimal", precision: 16, scale: 2, nullable: true, transformer: new DecimalTransformer() }) lateFee: Decimal | null; + @Column({ type: "boolean", default: false }) + isRecurring: boolean; + + @Column({ type: "enum", enum: RecurringPeriodEnum, nullable: true }) + recurringPeriod?: RecurringPeriodEnum; + + @Column({ type: "int", default: 0 }) + maxRecurringCycles: number; + + @Column({ type: "int", default: 0 }) + currentRecurringCycle: number; + //--------------------------------------------- @OneToMany(() => InvoiceItem, (invoiceItem) => invoiceItem.invoice, { cascade: true }) diff --git a/src/modules/invoices/providers/invoices.service.ts b/src/modules/invoices/providers/invoices.service.ts index db65908..f2c92e5 100755 --- a/src/modules/invoices/providers/invoices.service.ts +++ b/src/modules/invoices/providers/invoices.service.ts @@ -21,7 +21,7 @@ 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 { 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"; @@ -48,13 +48,41 @@ export class InvoicesService { ///********************************** */ + 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(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - 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, @@ -66,14 +94,22 @@ export class InvoicesService { 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); @@ -104,6 +140,10 @@ export class InvoicesService { }; } 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(); @@ -575,6 +615,8 @@ export class InvoicesService { }, ); + 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( diff --git a/src/modules/invoices/queue/invoice.processor.ts b/src/modules/invoices/queue/invoice.processor.ts index e322596..553bbfc 100755 --- a/src/modules/invoices/queue/invoice.processor.ts +++ b/src/modules/invoices/queue/invoice.processor.ts @@ -69,18 +69,41 @@ export class InvoiceProcessor extends WorkerProcessor { } //********************************** */ + private async validateRecurringInvoice(invoice: Invoice): Promise { + if (!invoice.isRecurring) throw new Error(`Invoice ${invoice.id} is not set up for recurring billing`); + + if (invoice.maxRecurringCycles && invoice.currentRecurringCycle >= invoice.maxRecurringCycles) { + throw new Error(`Maximum recurring cycles (${invoice.maxRecurringCycles}) reached for invoice ${invoice.id}`); + } + + if (invoice.status === InvoiceStatus.CANCELLED) throw new Error(`Cannot create recurring invoice for cancelled invoice ${invoice.id}`); + } + + //********************************** */ + private async handleAdminCreatedInvoice(invoice: Invoice, queryRunner: QueryRunner): Promise { this.logger.verbose(`Creating admin-initiated recurring invoice in draft status`); + await this.validateRecurringInvoice(invoice); + const newInvoice = queryRunner.manager.create(Invoice, { user: invoice.user, status: InvoiceStatus.DRAFT, dueDate: dayjs().add(INVOICE.DUEDATE, "day").toDate(), totalPrice: invoice.totalPrice, + originalPrice: invoice.originalPrice, tax: invoice.tax, items: invoice.items, + isRecurring: invoice.isRecurring, + recurringPeriod: invoice.recurringPeriod, + maxRecurringCycles: invoice.maxRecurringCycles, + currentRecurringCycle: invoice.currentRecurringCycle + 1, }); + // Update the original invoice's current recurring cycle + invoice.currentRecurringCycle += 1; + await queryRunner.manager.save(Invoice, invoice); + await this.sendNotificationToAdmins(invoice, queryRunner); return queryRunner.manager.save(Invoice, newInvoice); @@ -90,12 +113,24 @@ export class InvoiceProcessor extends WorkerProcessor { private async handleSubscriptionInvoice(invoice: Invoice, queryRunner: QueryRunner): Promise { this.logger.verbose(`Creating subscription-based recurring invoice`); + await this.validateRecurringInvoice(invoice); + const userSubscriptionPlan = invoice.items[0]?.subscriptionPlan; - if (!userSubscriptionPlan) throw new Error(`No subscription plan found for invoice ${invoice.id}`); + if (!userSubscriptionPlan) { + throw new Error(`No subscription plan found for invoice ${invoice.id}`); + } + + if (userSubscriptionPlan.status !== SubscriptionStatus.ACTIVE) { + throw new Error(`Subscription plan ${userSubscriptionPlan.id} is not active`); + } const { plan } = userSubscriptionPlan; const dueDate = dayjs().add(INVOICE.DUEDATE, "day").toDate(); + // Update the original invoice's current recurring cycle + invoice.currentRecurringCycle += 1; + await queryRunner.manager.save(Invoice, invoice); + return this.invoicesService.createInvoiceForSubscription(invoice.user, plan, userSubscriptionPlan, dueDate, queryRunner); } //********************************** */