From f9cb3cf653a3c54435b73fc63e8da55075b9bda3 Mon Sep 17 00:00:00 2001 From: mahyargdz Date: Thu, 17 Apr 2025 09:52:54 +0330 Subject: [PATCH] fix: bug --- src/common/enums/message.enum.ts | 20 +- .../providers/danak-services.service.ts | 4 +- .../discounts/DTO/create-discount.dto.ts | 66 ++- .../discounts/DTO/update-discount.dto.ts | 45 +- src/modules/discounts/discounts.controller.ts | 102 ++-- src/modules/discounts/discounts.module.ts | 17 +- .../discounts/entities/discount.entity.ts | 9 + .../discounts/providers/discounts.service.ts | 446 ++++++++++++------ .../discounts/utils/discount-validator.ts | 86 ++++ .../invoices/DTO/apply-discount.dto.ts | 12 + src/modules/invoices/invoices.controller.ts | 27 +- src/modules/invoices/invoices.module.ts | 8 +- 12 files changed, 520 insertions(+), 322 deletions(-) create mode 100644 src/modules/discounts/utils/discount-validator.ts create mode 100644 src/modules/invoices/DTO/apply-discount.dto.ts diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index b37d975..4af4d0c 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -457,7 +457,7 @@ export const enum InvoiceMessage { INVOICE_IS_OVERDUE = "صورت حساب منقضی شده است", INVOICE_PAID = "صورت حساب با موفقیت پرداخت شد", DISCOUNT_APPLIED = "تخفیف با موفقیت اعمال شد", - DISCOUNT_CANCELLED = "تخفیف با موفقیت لغو شد", + DISCOUNT_CANCELED = "تخفیف با موفقیت لغو شد", INVOICE_IS_REJECTED = "صورت حساب قبلا رد شده است", INVOICE_CAN_NOT_PAID = "صورت حساب قابل پرداخت نیست", INVALID_DATE = "تاریخ وارد شده معتبر نیست", @@ -465,6 +465,11 @@ export const enum InvoiceMessage { RECURRING_PERIOD_REQUIRED = "دوره تکرار مورد نیاز است", INVOICE_CAN_NOT_UPDATE = "صورت حساب قابل ویرایش نیست", INVOICE_UPDATED = "صورت حساب با موفقیت به روز رسانی شد", + DISCOUNT_CODE_REQUIRED = "کد تخفیف الزامی است", + DISCOUNT_CODE_MUST_BE_A_STRING = "کد تخفیف باید یک رشته باشد", + DISCOUNT_CODE_LENGTH = "طول کد تخفیف باید بین ۱ تا ۱۰۰ کاراکتر باشد", + ALREADY_HAS_DISCOUNT = "این صورت حساب قبلا دارای تخفیف است", + NO_DISCOUNT = "این صورت حساب دارای تخفیف نیست", } export const enum LearningMessage { @@ -498,6 +503,19 @@ export const enum DiscountMessage { IS_ACTIVE_MUST_BE_A_BOOLEAN = "وضعیت فعال بودن باید یک بولین باشد", TARGET_PRODUCTS_MUST_BE_AN_ARRAY = "تعداد محصولات باید یک آرایه باشد", EACH_TARGET_PRODUCT_MUST_BE_A_STRING = "هر محصول باید یک رشته باشد", + CODE_REQUIRED = "کد تخفیف الزامی است", + CODE_STRING = "کد تخفیف باید یک رشته باشد", + DIRECT_REQUIRED = "وضعیت مستقیم بودن تخفیف الزامی است", + DIRECT_MUST_BE_A_BOOLEAN = "وضعیت مستقیم بودن تخفیف باید یک بولین باشد", + CODE_ALREADY_EXISTS = "کد تخفیف قبلا ثبت شده است", + CREATED = "تخفیف با موفقیت ایجاد شد", + NOT_FOUND = "تخفیف یافت نشد", + INVALID_CODE = "کد تخفیف نامعتبر است", + EXPIRED = "تخفیف منقضی شده است", + MAX_USAGE_REACHED = "تعداد استفاده از این کد تخفیف به حداکثر رسیده است", + MAX_USER_USAGE_REACHED = "شما قبلا از حداکثر تعداد مجاز این کد تخفیف استفاده کرده‌اید", + NOT_APPLICABLE = "این کد تخفیف برای محصولات انتخاب شده معتبر نیست", + VALUE_REQUIRED = "مقدار تخفیف الزامی است", } export const enum EmailMessage { diff --git a/src/modules/danak-services/providers/danak-services.service.ts b/src/modules/danak-services/providers/danak-services.service.ts index 63441c9..85304f9 100755 --- a/src/modules/danak-services/providers/danak-services.service.ts +++ b/src/modules/danak-services/providers/danak-services.service.ts @@ -385,7 +385,9 @@ export class DanakServicesService { async getDanakServiceByIdPublic(serviceId: string) { const danakService = await this.danakServicesRepository.getDanakServiceById(serviceId, false); if (!danakService) throw new BadRequestException(ServiceMessage.SERVICE_NOT_FOUND_BY_ID); - return { danakService }; + const averageRating = await this.danakServiceReviewRepository.getAverageRating(danakService.id); + + return { danakService, averageRating }; } /******************************************** */ diff --git a/src/modules/discounts/DTO/create-discount.dto.ts b/src/modules/discounts/DTO/create-discount.dto.ts index 7fb54be..d8fd1db 100644 --- a/src/modules/discounts/DTO/create-discount.dto.ts +++ b/src/modules/discounts/DTO/create-discount.dto.ts @@ -1,18 +1,6 @@ -import { ApiProperty } from "@nestjs/swagger"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { Type } from "class-transformer"; -import { - IsArray, - IsBoolean, - IsDateString, - IsEnum, - IsNotEmpty, - IsNumber, - IsOptional, - IsString, - Max, - Min, - ValidateIf, -} from "class-validator"; +import { IsArray, IsBoolean, IsDateString, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, Min, ValidateIf } from "class-validator"; import { DiscountMessage } from "../../../common/enums/message.enum"; import { DiscountType } from "../enums/discount-type.enum"; @@ -25,35 +13,61 @@ export class CreateDiscountDto { @IsNotEmpty({ message: DiscountMessage.DISCOUNT_TYPE_REQUIRED }) @IsEnum(DiscountType, { message: DiscountMessage.INVALID_DISCOUNT_TYPE }) - @ApiProperty({ description: "The type of discount", example: DiscountType.PERCENTAGE }) + @ApiProperty({ description: "The type of discount", example: DiscountType.PERCENTAGE, enum: DiscountType }) type: DiscountType; + @IsNotEmpty({ message: DiscountMessage.DIRECT_REQUIRED }) + @IsBoolean({ message: DiscountMessage.DIRECT_MUST_BE_A_BOOLEAN }) + @ApiProperty({ + description: "Indicates if the discount is a direct discount on services (true) or a code-based discount for invoices (false)", + example: false, + }) + direct: boolean; + + @IsNotEmpty({ message: DiscountMessage.VALUE_REQUIRED }) @IsNumber({}, { message: DiscountMessage.VALUE_MUST_BE_A_NUMBER }) - @Min(0, { message: DiscountMessage.VALUE_MUST_BE_AT_LEAST_0 }) - @Max(100, { message: DiscountMessage.VALUE_MUST_NOT_EXCEED_100 }) - @Type(() => Number) @ApiProperty({ description: "The value of the discount", example: 20 }) value: number; - @IsDateString({}, { message: DiscountMessage.START_DATE_MUST_BE_A_VALID_DATE_STRING }) @IsNotEmpty({ message: DiscountMessage.START_DATE_IS_REQUIRED }) + @IsDateString({}, { message: DiscountMessage.START_DATE_MUST_BE_A_VALID_DATE_STRING }) @ApiProperty({ description: "The start date of the discount", example: "2023-06-01T00:00:00Z" }) startDate: string; - @IsDateString({}, { message: DiscountMessage.END_DATE_MUST_BE_A_VALID_DATE_STRING }) @IsNotEmpty({ message: DiscountMessage.END_DATE_IS_REQUIRED }) + @IsDateString({}, { message: DiscountMessage.END_DATE_MUST_BE_A_VALID_DATE_STRING }) @ApiProperty({ description: "The end date of the discount", example: "2023-06-30T23:59:59Z" }) endDate: string; - @IsBoolean({ message: DiscountMessage.IS_ACTIVE_MUST_BE_A_BOOLEAN }) @IsOptional() + @IsBoolean({ message: DiscountMessage.IS_ACTIVE_MUST_BE_A_BOOLEAN }) @ApiProperty({ description: "Indicates if the discount is active", example: true }) isActive?: boolean; - @IsArray({ message: "Target products must be an array" }) @IsOptional() - @ValidateIf((o) => o.targetProducts !== null && o.targetProducts !== undefined) - @IsString({ each: true, message: "Each target product must be a string" }) - @ApiProperty({ description: "List of target product IDs", example: ["prod-1", "prod-2"] }) - targetProducts?: string[]; + @IsNumber({}, { message: "Max usage must be a number" }) + @Min(0, { message: "Max usage must be at least 0" }) + @Type(() => Number) + @ApiProperty({ description: "Maximum number of times this discount can be used (null for unlimited)", example: 1000, required: false }) + maxUsage?: number; + + @IsOptional() + @IsNumber({}, { message: "Max usage per user must be a number" }) + @Min(0, { message: "Max usage per user must be at least 0" }) + @Type(() => Number) + @ValidateIf((o) => !o.direct) + @ApiPropertyOptional({ description: "Maximum number of times a single user can use this discount (null for unlimited)", example: 1 }) + maxUsagePerUser?: number; + + @IsOptional() + @IsNotEmpty({ message: DiscountMessage.TARGET_PRODUCTS_MUST_BE_AN_ARRAY }) + @IsArray({ message: DiscountMessage.TARGET_PRODUCTS_MUST_BE_AN_ARRAY }) + @ValidateIf((o) => o.direct) + @IsString({ each: true, message: DiscountMessage.EACH_TARGET_PRODUCT_MUST_BE_A_STRING }) + @ApiPropertyOptional({ + description: "Array of service IDs this discount applies to (empty array means applies to all services)", + example: ["service-id-1", "service-id-2"], + type: [String], + }) + targetServices?: string[]; } diff --git a/src/modules/discounts/DTO/update-discount.dto.ts b/src/modules/discounts/DTO/update-discount.dto.ts index ed0fd90..0ee1a5d 100644 --- a/src/modules/discounts/DTO/update-discount.dto.ts +++ b/src/modules/discounts/DTO/update-discount.dto.ts @@ -1,44 +1,5 @@ -import { Type } from "class-transformer"; -import { IsArray, IsBoolean, IsDateString, IsEnum, IsNumber, IsOptional, IsString, Max, Min, ValidateIf } from "class-validator"; +import { PartialType } from "@nestjs/swagger"; -import { DiscountStatus } from "../enums/discount-status.enum"; -import { DiscountType } from "../enums/discount-type.enum"; +import { CreateDiscountDto } from "./create-discount.dto"; -export class UpdateDiscountDto { - @IsString() - @IsOptional() - title?: string; - - @IsEnum(DiscountType) - @IsOptional() - type?: DiscountType; - - @IsEnum(DiscountStatus) - @IsOptional() - status?: DiscountStatus; - - @IsNumber() - @IsOptional() - @Min(0) - @Max(100) - @Type(() => Number) - value?: number; - - @IsDateString() - @IsOptional() - startDate?: string; - - @IsDateString() - @IsOptional() - endDate?: string; - - @IsBoolean() - @IsOptional() - isActive?: boolean; - - @IsArray() - @IsOptional() - @ValidateIf((o) => o.targetProducts !== null && o.targetProducts !== undefined) - @IsString({ each: true }) - targetProducts?: string[]; -} +export class UpdateDiscountDto extends PartialType(CreateDiscountDto) {} diff --git a/src/modules/discounts/discounts.controller.ts b/src/modules/discounts/discounts.controller.ts index 96975a5..e8222e5 100644 --- a/src/modules/discounts/discounts.controller.ts +++ b/src/modules/discounts/discounts.controller.ts @@ -1,77 +1,33 @@ -// import { Body, Controller, Post } from "@nestjs/common"; -// import { ApiOperation, ApiResponse } from "@nestjs/swagger"; +import { Body, Controller, Get, Param, Post } from "@nestjs/common"; +import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; -// import { CreateDiscountDto } from "./DTO/create-discount.dto"; -// import { Discount } from "./entities/discount.entity"; -// import { DiscountsService } from "./providers/discounts.service"; -// import { AdminRoute } from "../../common/decorators/admin.decorator"; -// import { AuthGuards } from "../../common/decorators/auth-guard.decorator"; -// import { PermissionsDec } from "../../common/decorators/permission.decorator"; -// import { PermissionEnum } from "../users/enums/permission.enum"; +import { CreateDiscountDto } from "./DTO/create-discount.dto"; +import { DiscountsService } from "./providers/discounts.service"; +import { AdminRoute } from "../../common/decorators/admin.decorator"; +import { AuthGuards } from "../../common/decorators/auth-guard.decorator"; +import { PermissionsDec } from "../../common/decorators/permission.decorator"; +import { PermissionEnum } from "../users/enums/permission.enum"; -// @Controller("discounts") -// @AuthGuards() -// export class DiscountsController { -// constructor(private readonly discountsService: DiscountsService) {} +@ApiTags("Discounts") +@Controller("discounts") +@AuthGuards() +export class DiscountsController { + constructor(private readonly discountsService: DiscountsService) {} -// @ApiOperation({ summary: "Create a new discount" }) -// @AdminRoute() -// @PermissionsDec(PermissionEnum.DISCOUNTS) -// @Post() -// @ApiResponse({ status: 201, description: "Discount created successfully" }) -// create(@Body() createDiscountDto: CreateDiscountDto): Promise { -// return this.discountsService.create(createDiscountDto); -// } + @ApiOperation({ summary: "Create a new discount" }) + @AdminRoute() + @PermissionsDec(PermissionEnum.DISCOUNTS) + @Post() + @ApiResponse({ status: 201, description: "Discount created successfully" }) + create(@Body() createDiscountDto: CreateDiscountDto) { + return this.discountsService.create(createDiscountDto); + } -// // @AdminRoute() -// // @PermissionsDec(PermissionEnum.DISCOUNTS) -// // @Get() -// // @ApiOperation({ summary: "Get all discounts" }) -// // findAll(@Query() query: any) {} - -// // @AdminRoute() -// // @PermissionsDec(PermissionEnum.DISCOUNTS) -// // @Get(":id") -// // @ApiOperation({ summary: "Get discount by ID" }) -// // findOne(@Param("id") id: string): Promise { -// // return this.discountsService.findById(id); -// // } - -// // @AdminRoute() -// // @PermissionsDec(PermissionEnum.DISCOUNTS) -// // @Get(":id/products") -// // @ApiOperation({ summary: "Get products for a discount" }) -// // getProducts(@Param("id") id: string): Promise { -// // return this.discountsService.getProductsForDiscount(id); -// // } - -// // @AdminRoute() -// // @PermissionsDec(PermissionEnum.DISCOUNTS) -// // @Put(":id") -// // @ApiOperation({ summary: "Update discount" }) -// // update(@Param("id") id: string, @Body() updateDiscountDto: UpdateDiscountDto): Promise { -// // return this.discountsService.update(id, updateDiscountDto); -// // } - -// // @AdminRoute() -// // @PermissionsDec(PermissionEnum.DISCOUNTS) -// // @Patch(":id/toggle-active") -// // @ApiOperation({ summary: "Toggle discount active state" }) -// // toggleActive(@Param("id") id: string, @Body() toggleActiveDto: ToggleActiveDto): Promise { -// // return this.discountsService.toggleActive(id, toggleActiveDto.isActive); -// // } - -// // @AdminRoute() -// // @PermissionsDec(PermissionEnum.DISCOUNTS) -// // @Delete(":id") -// // @ApiOperation({ summary: "Delete discount" }) -// // remove(@Param("id") id: string): Promise { -// // return this.discountsService.delete(id); -// // } - -// // @Post("validate") -// // @ApiOperation({ summary: "Validate discount code" }) -// // validateDiscount(@Body() validateDiscountDto: ValidateDiscountDto, @UserDec() user: User): Promise { -// // return this.discountsService.validateDiscount(validateDiscountDto.code, user, validateDiscountDto.productId); -// // } -// } + @ApiOperation({ summary: "Get discount by ID" }) + @AdminRoute() + @PermissionsDec(PermissionEnum.DISCOUNTS) + @Get(":id") + findOne(@Param("id") id: string) { + return this.discountsService.findById(id); + } +} diff --git a/src/modules/discounts/discounts.module.ts b/src/modules/discounts/discounts.module.ts index c327e04..de11cd4 100644 --- a/src/modules/discounts/discounts.module.ts +++ b/src/modules/discounts/discounts.module.ts @@ -1,20 +1,21 @@ import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; -// import { DiscountsController } from "./discounts.controller"; +import { DiscountsController } from "./discounts.controller"; import { UsersModule } from "../users/users.module"; import { DiscountService } from "./entities/discount-product.entity"; import { Discount } from "./entities/discount.entity"; import { UsageDiscount } from "./entities/usage-discount.entity"; -// import { DiscountsService } from "./providers/discounts.service"; -// import { DiscountServicesRepository } from "./repositories/discount-services.repository"; -// import { DiscountRepository } from "./repositories/discounts.repository"; -// import { UsageDiscountRepository } from "./repositories/usage-discounts.repository"; +import { DiscountsService } from "./providers/discounts.service"; +import { DiscountServicesRepository } from "./repositories/discount-services.repository"; +import { DiscountRepository } from "./repositories/discounts.repository"; +import { UsageDiscountRepository } from "./repositories/usage-discounts.repository"; +import { DiscountValidator } from "./utils/discount-validator"; @Module({ imports: [TypeOrmModule.forFeature([Discount, UsageDiscount, DiscountService]), UsersModule], - // controllers: [DiscountsController], - // providers: [DiscountsService, DiscountRepository, UsageDiscountRepository, DiscountServicesRepository], - // exports: [DiscountsService], + controllers: [DiscountsController], + providers: [DiscountsService, DiscountRepository, UsageDiscountRepository, DiscountServicesRepository, DiscountValidator], + exports: [DiscountsService, DiscountValidator], }) export class DiscountsModule {} diff --git a/src/modules/discounts/entities/discount.entity.ts b/src/modules/discounts/entities/discount.entity.ts index 583c195..a666df9 100644 --- a/src/modules/discounts/entities/discount.entity.ts +++ b/src/modules/discounts/entities/discount.entity.ts @@ -32,6 +32,15 @@ export class Discount extends BaseEntity { @Column({ type: "boolean", default: true }) isActive: boolean; + @Column({ type: "boolean", default: false }) + direct: boolean; + + @Column({ type: "int", nullable: true }) + maxUsage: number; + + @Column({ type: "int", nullable: true }) + maxUsagePerUser: number; + @OneToMany(() => DiscountService, (discountService) => discountService.discount, { cascade: true }) discountServices: DiscountService[]; diff --git a/src/modules/discounts/providers/discounts.service.ts b/src/modules/discounts/providers/discounts.service.ts index 8694288..b431f1a 100644 --- a/src/modules/discounts/providers/discounts.service.ts +++ b/src/modules/discounts/providers/discounts.service.ts @@ -1,194 +1,326 @@ -// import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; -// // eslint-disable-next-line import/no-named-as-default -// import Decimal from "decimal.js"; +import { randomBytes } from "node:crypto"; -// import { User } from "../../users/entities/user.entity"; -// import { CreateDiscountDto } from "../DTO/create-discount.dto"; -// import { UpdateDiscountDto } from "../DTO/update-discount.dto"; -// import { Discount } from "../entities/discount.entity"; -// import { DiscountType } from "../enums/discount-type.enum"; +import { BadRequestException, Injectable } from "@nestjs/common"; +import dayjs from "dayjs"; +// eslint-disable-next-line import/no-named-as-default +import Decimal from "decimal.js"; +import { DataSource, QueryRunner } from "typeorm"; + +import { DiscountMessage, InvoiceMessage } from "../../../common/enums/message.enum"; +import { Invoice } from "../../invoices/entities/invoice.entity"; +import { InvoiceStatus } from "../../invoices/enums/invoice-status.enum"; +import { CreateDiscountDto } from "../DTO/create-discount.dto"; +import { Discount } from "../entities/discount.entity"; +import { UsageDiscount } from "../entities/usage-discount.entity"; +import { DiscountType } from "../enums/discount-type.enum"; // import { DiscountServicesRepository } from "../repositories/discount-services.repository"; -// import { DiscountRepository } from "../repositories/discounts.repository"; -// import { UsageDiscountRepository } from "../repositories/usage-discounts.repository"; +import { DiscountRepository } from "../repositories/discounts.repository"; +import { UsageDiscountRepository } from "../repositories/usage-discounts.repository"; +import { DiscountValidator } from "../utils/discount-validator"; -// @Injectable() -// export class DiscountsService { -// constructor( -// private readonly discountRepository: DiscountRepository, -// private readonly discountServicesRepository: DiscountServicesRepository, -// private readonly usageDiscountRepository: UsageDiscountRepository, -// ) {} +@Injectable() +export class DiscountsService { + constructor( + private readonly discountRepository: DiscountRepository, + // private readonly discountServicesRepository: DiscountServicesRepository, + private readonly usageDiscountRepository: UsageDiscountRepository, + private readonly dataSource: DataSource, + private readonly discountValidator: DiscountValidator, + ) {} -// async create(createDiscountDto: CreateDiscountDto) { -// const existingDiscount = await this.discountRepository.findByCode(createDiscountDto.title); -// if (existingDiscount) { -// throw new BadRequestException(DISCOUNT_ERROR_MESSAGES.ALREADY_EXISTS); -// } + async create(createDiscountDto: CreateDiscountDto) { + const queryRunner = this.dataSource.createQueryRunner(); + const { direct } = createDiscountDto; -// // Create the discount without product relations -// const { targetProducts, ...discountData } = createDiscountDto; + try { + await queryRunner.connect(); + await queryRunner.startTransaction(); -// const discount = await this.discountRepository.create({ -// ...discountData, -// startDate: new Date(createDiscountDto.startDate), -// endDate: new Date(createDiscountDto.endDate), -// status: createDiscountDto.status || DiscountStatus.ACTIVE, -// isActive: createDiscountDto.isActive !== undefined ? createDiscountDto.isActive : true, -// }); + const discount = queryRunner.manager.create(this.discountRepository.target, createDiscountDto); -// // Add product relations if provided -// if (targetProducts && targetProducts.length > 0) { -// await this.discountServicesRepository.createMany(discount.id, targetProducts); -// } + if (!direct) { + const code = await this.generateDiscountCode(); + discount.code = code; + } -// // Fetch the complete discount with relations -// const fullDiscount = await this.discountRepository.findById(discount.id); -// if (!fullDiscount) { -// throw new NotFoundException(DISCOUNT_ERROR_MESSAGES.NOT_FOUND); -// } -// return fullDiscount; -// } + await queryRunner.commitTransaction(); -// async findAll(options?: any): Promise<[Discount[], number]> { -// return this.discountRepository.findAll(options); -// } + return { + message: DiscountMessage.CREATED, + }; + } catch (error) { + await queryRunner.rollbackTransaction(); + throw error; + } finally { + await queryRunner.release(); + } + } -// async findById(id: string): Promise { -// const discount = await this.discountRepository.findById(id); -// if (!discount) { -// throw new NotFoundException(DISCOUNT_ERROR_MESSAGES.NOT_FOUND); -// } -// return discount; -// } + async findById(id: string) { + const discount = await this.discountRepository.findById(id); -// async update(id: string, updateDiscountDto: UpdateDiscountDto): Promise { -// // Validate discount exists -// await this.findById(id); + if (!discount) throw new BadRequestException(DiscountMessage.NOT_FOUND); -// // Handle product relations separately -// const { targetProducts, startDate: startDateStr, endDate: endDateStr, ...otherFields } = updateDiscountDto; + return { discount }; + } -// // Initialize update data with other fields -// const updateData: Partial = { ...otherFields }; + async validateDiscountCode(code: string, userId: string, invoice: Invoice, queryRunner: QueryRunner) { + // Find the discount by code + const discount = await queryRunner.manager.findOne(Discount, { + where: { + code, + isActive: true, + direct: false, + }, + relations: ["discountServices"], + }); -// // Add date fields if they exist -// if (startDateStr) { -// updateData.startDate = new Date(startDateStr); -// } + if (!discount) { + throw new BadRequestException(DiscountMessage.INVALID_CODE); + } -// if (endDateStr) { -// updateData.endDate = new Date(endDateStr); -// } + // Check if the discount is currently active (time period) + const now = dayjs(); + if (now.isBefore(dayjs(discount.startDate)) || now.isAfter(dayjs(discount.endDate))) { + throw new BadRequestException(DiscountMessage.EXPIRED); + } -// // Update the discount entity -// const updatedDiscount = await this.discountRepository.update(id, updateData); -// if (!updatedDiscount) { -// throw new NotFoundException(DISCOUNT_ERROR_MESSAGES.NOT_FOUND); -// } + // Check if the discount has reached its maximum usage + if (discount.maxUsage !== null && discount.usedCount >= discount.maxUsage) { + throw new BadRequestException(DiscountMessage.MAX_USAGE_REACHED); + } -// // Handle product relations if provided -// if (targetProducts !== undefined) { -// // Remove existing relations -// await this.discountServicesRepository.deleteByDiscountId(id); + // Check if the user has reached their maximum usage for this discount + if (discount.maxUsagePerUser !== null) { + const userUsageCount = await this.usageDiscountRepository.countByUserAndDiscount(userId, discount.id); + if (userUsageCount >= discount.maxUsagePerUser) { + throw new BadRequestException(DiscountMessage.MAX_USER_USAGE_REACHED); + } + } -// // Add new relations if not empty -// if (targetProducts.length > 0) { -// await this.discountServicesRepository.createMany(id, targetProducts); -// } -// } + // Check if the discount applies to the items in the invoice + if (!this.discountValidator.validateItemsForDiscount(invoice, discount)) { + throw new BadRequestException(DiscountMessage.NOT_APPLICABLE); + } -// // Return the updated discount with relations -// return this.findById(id); -// } + return discount; + } -// async delete(id: string): Promise { -// const discount = await this.findById(id); -// await this.discountRepository.delete(discount.id); -// } + /** + * Applies a discount code to an invoice + * @param invoiceId The invoice ID + * @param code The discount code + * @param userId The user ID + * @returns The updated invoice + */ + async applyDiscountToInvoice(invoiceId: string, code: string, userId: string) { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); -// async toggleActive(id: string, isActive: boolean): Promise { -// await this.findById(id); -// const updatedDiscount = await this.discountRepository.update(id, { isActive }); -// if (!updatedDiscount) { -// throw new NotFoundException(DISCOUNT_ERROR_MESSAGES.NOT_FOUND); -// } -// return updatedDiscount; -// } + try { + // Get the invoice + const invoice = await queryRunner.manager.findOne(Invoice, { + where: { + id: invoiceId, + user: { id: userId }, + status: InvoiceStatus.PENDING, + }, + relations: ["items", "items.subscriptionPlan", "items.subscriptionPlan.plan", "items.subscriptionPlan.plan.service"], + }); -// async validateDiscount(code: string, user: User, productId?: string): Promise { -// const discount = await this.discountRepository.findValidByCode(code); + if (!invoice) { + throw new BadRequestException(InvoiceMessage.NOT_FOUND_BY_ID); + } -// if (!discount) { -// throw new BadRequestException(DISCOUNT_ERROR_MESSAGES.INVALID); -// } + // Check if the invoice already has a discount + if (invoice.discount) { + throw new BadRequestException(InvoiceMessage.ALREADY_HAS_DISCOUNT); + } -// // Check if user has already used this discount -// const usageCount = await this.usageDiscountRepository.countByUserAndDiscount(user.id, discount.id); -// if (usageCount > 0) { -// throw new BadRequestException(DISCOUNT_ERROR_MESSAGES.ALREADY_USED); -// } + // Validate the discount code + const discount = await this.validateDiscountCode(code, userId, invoice, queryRunner); -// // Check if product is valid for this discount -// if (productId && discount.discountServices && discount.discountServices.length > 0) { -// const hasProduct = await this.discountServicesRepository.hasProductForDiscount(discount.id, productId); -// if (!hasProduct) { -// throw new BadRequestException(DISCOUNT_ERROR_MESSAGES.INVALID_PRODUCT); -// } -// } + // Calculate the discount amount + const discountAmount = this.discountValidator.calculateDiscountAmount(invoice, discount); -// return discount; -// } + // Calculate the new total price + const newTotalPrice = this.discountValidator.calculateDiscountedTotalPrice(invoice, discountAmount); -// async applyDiscount(amount: number, discountId: string, productId?: string): Promise { -// const discount = await this.findById(discountId); + // Update the invoice + invoice.discount = discount; + invoice.totalPrice = new Decimal(newTotalPrice); -// if (!discount.isValid) { -// throw new BadRequestException(DISCOUNT_ERROR_MESSAGES.INVALID); -// } + await queryRunner.manager.save(Invoice, invoice); -// // Check if product is valid for this discount -// if (productId && discount.discountServices && discount.discountServices.length > 0) { -// const hasProduct = await this.discountServicesRepository.hasProductForDiscount(discount.id, productId); -// if (!hasProduct) { -// throw new BadRequestException(DISCOUNT_ERROR_MESSAGES.INVALID_PRODUCT); -// } -// } + // Record the discount usage + const usageDiscount = queryRunner.manager.create(UsageDiscount, { + discount, + user: { id: userId }, + appliedAmount: discountAmount, + usedAt: new Date(), + }); -// let discountAmount = 0; + await queryRunner.manager.save(UsageDiscount, usageDiscount); -// if (discount.type === DiscountType.PERCENTAGE) { -// discountAmount = (amount * discount.value) / 100; -// } else if (discount.type === DiscountType.FIXED_AMOUNT) { -// discountAmount = discount.value; -// } + // Increment the discount usage count + discount.usedCount += 1; + await queryRunner.manager.save(Discount, discount); -// // Ensure discount doesn't exceed the original amount -// if (discountAmount > amount) { -// discountAmount = amount; -// } + await queryRunner.commitTransaction(); -// return discountAmount; -// } + return { + message: InvoiceMessage.DISCOUNT_APPLIED, + invoice, + }; + } catch (error) { + await queryRunner.rollbackTransaction(); + throw error; + } finally { + await queryRunner.release(); + } + } -// async recordDiscountUsage(userId: string, discountId: string, appliedAmount: number): Promise { -// const discount = await this.findById(discountId); + /** + * Cancels a discount applied to an invoice + * @param invoiceId The invoice ID + * @param userId The user ID + * @returns The updated invoice + */ + async cancelInvoiceDiscount(invoiceId: string, userId: string) { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); -// // Increment usage count -// await this.discountRepository.update(discount.id, { -// usedCount: discount.usedCount + 1, -// }); + try { + // Get the invoice + const invoice = await queryRunner.manager.findOne(Invoice, { + where: { + id: invoiceId, + user: { id: userId }, + status: InvoiceStatus.PENDING, + }, + relations: ["discount", "items"], + }); -// // Record usage -// await this.usageDiscountRepository.create({ -// user: { id: userId } as User, -// discount: { id: discountId } as Discount, -// appliedAmount: new Decimal(appliedAmount), -// usedAt: new Date(), -// }); -// } + if (!invoice) { + throw new BadRequestException(InvoiceMessage.NOT_FOUND_BY_ID); + } -// async getProductsForDiscount(discountId: string): Promise { -// const discountServices = await this.discountServicesRepository.findByDiscountId(discountId); -// return discountServices.map((service) => service.danakService.id); -// } -// } + // Check if the invoice has a discount + if (!invoice.discount) { + throw new BadRequestException(InvoiceMessage.NO_DISCOUNT); + } + + // Find the usage record + const usageDiscount = await this.usageDiscountRepository.findByUserAndDiscount(userId, invoice.discount.id); + + if (usageDiscount) { + // Delete the usage record + await queryRunner.manager.remove(usageDiscount); + + // Decrement the discount usage count + invoice.discount.usedCount -= 1; + await queryRunner.manager.save(Discount, invoice.discount); + } + + // Recalculate the total price without the discount + // This requires recalculating based on the original item prices + const originalTotalPrice = invoice.items.reduce((sum, item) => { + const itemTotal = new Decimal(item.unitPrice) + .mul(item.count) + .minus(new Decimal(item.unitPrice).mul(item.count).mul(item.discount).div(100)); + return sum.add(itemTotal); + }, new Decimal(0)); + + // Add tax to the total + const totalWithTax = originalTotalPrice.add(invoice.tax || 0); + + // Remove the discount from the invoice + // invoice.discount = null; + invoice.totalPrice = new Decimal(totalWithTax); + + await queryRunner.manager.save(Invoice, invoice); + + await queryRunner.commitTransaction(); + + return { + message: InvoiceMessage.DISCOUNT_CANCELED, + invoice, + }; + } catch (error) { + await queryRunner.rollbackTransaction(); + throw error; + } finally { + await queryRunner.release(); + } + } + + /** + * Applies a direct discount to a service price + * When calculating service prices, this discount should be considered + * @param serviceId The service ID + * @returns The discount information for the service + */ + async getDirectDiscountForService(serviceId: string) { + // Find active direct discounts for this service + const discounts = await this.discountRepository + .createQueryBuilder("discount") + .innerJoin("discount.discountServices", "ds") + .where("discount.isActive = :isActive", { isActive: true }) + .andWhere("discount.direct = :direct", { direct: true }) + .andWhere("discount.startDate <= :now", { now: new Date() }) + .andWhere("discount.endDate >= :now", { now: new Date() }) + .andWhere("ds.danakService.id = :serviceId", { serviceId }) + .getMany(); + + if (!discounts || discounts.length === 0) { + return null; + } + + // For simplicity, we're returning the first valid discount + // In a more complex system, you might want to apply the best discount + return discounts[0]; + } + + /** + * Calculate the discounted price for a service + * @param serviceId The service ID + * @param originalPrice The original price + * @returns The discounted price and discount info + */ + async calculateDiscountedPrice(serviceId: string, originalPrice: number) { + const discount = await this.getDirectDiscountForService(serviceId); + + if (!discount) { + return { + originalPrice, + discountedPrice: originalPrice, + discount: null, + }; + } + + let discountedPrice: number; + + if (discount.type === DiscountType.PERCENTAGE) { + discountedPrice = new Decimal(originalPrice) + .mul(100 - discount.value) + .div(100) + .toNumber(); + } else { + discountedPrice = Math.max(0, originalPrice - discount.value); + } + + return { + originalPrice, + discountedPrice, + discount, + }; + } + + //*************************** */ + private async generateDiscountCode() { + const code = randomBytes(8).toString("hex"); + return code; + } +} diff --git a/src/modules/discounts/utils/discount-validator.ts b/src/modules/discounts/utils/discount-validator.ts new file mode 100644 index 0000000..f3bd2b2 --- /dev/null +++ b/src/modules/discounts/utils/discount-validator.ts @@ -0,0 +1,86 @@ +import { Injectable } from "@nestjs/common"; +// eslint-disable-next-line import/no-named-as-default +import Decimal from "decimal.js"; + +import { InvoiceItem } from "../../invoices/entities/invoice-item.entity"; +import { Invoice } from "../../invoices/entities/invoice.entity"; +import { Discount } from "../entities/discount.entity"; +import { DiscountType } from "../enums/discount-type.enum"; + +@Injectable() +export class DiscountValidator { + /** + * Validates if the items in an invoice match with the services targeted by the discount + * @param invoice The invoice to check + * @param discount The discount to apply + * @returns true if valid, false otherwise + */ + public validateItemsForDiscount(invoice: Invoice, discount: Discount): boolean { + // If discount has no target services, it applies to all + if (!discount.discountServices || discount.discountServices.length === 0) { + return true; + } + + // Check if any item in the invoice matches the targeted services + const targetServiceIds = discount.discountServices.map((ds) => ds.danakService.id); + + // This implementation assumes there's a way to map from invoice items to services + // You'll need to adapt this based on your data model + const hasMatchingItem = invoice.items.some((item) => { + // For subscription-based items + if (item.subscriptionPlan && item.subscriptionPlan.plan && item.subscriptionPlan.plan.service) { + return targetServiceIds.includes(item.subscriptionPlan.plan.service.id); + } + + // For other types of items - implement according to your data model + // This is a placeholder - you need to adapt this to your specific implementation + return false; + }); + + return hasMatchingItem; + } + + /** + * Calculates the discount amount to apply to the invoice + * @param invoice The invoice to apply the discount to + * @param discount The discount to apply + * @returns The discount amount + */ + public calculateDiscountAmount(invoice: Invoice, discount: Discount): Decimal { + if (discount.type === DiscountType.PERCENTAGE) { + return new Decimal(invoice.totalPrice).mul(discount.value).div(100); + } else { + // For fixed amount, cap at the invoice total + return Decimal.min(new Decimal(discount.value), new Decimal(invoice.totalPrice)); + } + } + + /** + * Calculates the discounted price for an item + * @param item The item to calculate discount for + * @param discount The discount to apply + * @returns The discounted price + */ + public calculateItemDiscountedPrice(item: InvoiceItem, discount: Discount): Decimal { + const originalPrice = new Decimal(item.unitPrice).mul(item.count); + + if (discount.type === DiscountType.PERCENTAGE) { + return originalPrice.mul(new Decimal(100).minus(discount.value)).div(100); + } else { + // For fixed amount discounts, proportionally distribute the discount among items + // This is a simple implementation - you might want a more complex distribution logic + const discountAmount = Decimal.min(new Decimal(discount.value), originalPrice); + return originalPrice.minus(discountAmount); + } + } + + /** + * Calculates the updated total price after applying a discount + * @param invoice The invoice to calculate for + * @param discountAmount The discount amount to apply + * @returns The new total price as a number + */ + public calculateDiscountedTotalPrice(invoice: Invoice, discountAmount: Decimal): number { + return new Decimal(invoice.totalPrice).minus(discountAmount).toNumber(); + } +} diff --git a/src/modules/invoices/DTO/apply-discount.dto.ts b/src/modules/invoices/DTO/apply-discount.dto.ts new file mode 100644 index 0000000..39658e2 --- /dev/null +++ b/src/modules/invoices/DTO/apply-discount.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty, IsString, Length } from "class-validator"; + +import { InvoiceMessage } from "../../../common/enums/message.enum"; + +export class ApplyDiscountDto { + @IsNotEmpty({ message: InvoiceMessage.DISCOUNT_CODE_REQUIRED }) + @IsString({ message: InvoiceMessage.DISCOUNT_CODE_MUST_BE_A_STRING }) + @Length(1, 100, { message: InvoiceMessage.DISCOUNT_CODE_LENGTH }) + @ApiProperty({ description: "Discount code to apply to the invoice", example: "SUMMER20" }) + code: string; +} diff --git a/src/modules/invoices/invoices.controller.ts b/src/modules/invoices/invoices.controller.ts index a9baa08..8915f81 100755 --- a/src/modules/invoices/invoices.controller.ts +++ b/src/modules/invoices/invoices.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, Get, Param, Patch, Post, Query } from "@nestjs/common"; import { ApiOperation } from "@nestjs/swagger"; +import { ApplyDiscountDto } from "./DTO/apply-discount.dto"; import { CreateInvoiceDto } from "./DTO/create-invoice.dto"; import { InvoicesSearchQueryDto, UserInvoicesSearchQueryDto } from "./DTO/invoices-search-query.dto"; import { UpdateInvoiceDto } from "./DTO/update-invoice.dto"; @@ -11,12 +12,16 @@ import { PermissionsDec } from "../../common/decorators/permission.decorator"; import { UserDec } from "../../common/decorators/user.decorator"; import { ParamDto } from "../../common/DTO/param.dto"; import { VerifyOtpWithUserId } from "../auth/DTO/verify-otp.dto"; +import { DiscountsService } from "../discounts/providers/discounts.service"; import { PermissionEnum } from "../users/enums/permission.enum"; @Controller("invoices") @AuthGuards() export class InvoicesController { - constructor(private readonly invoiceService: InvoicesService) {} + constructor( + private readonly invoiceService: InvoicesService, + private readonly discountsService: DiscountsService, + ) {} @ApiOperation({ summary: "create an invoice ==> admin route" }) @PermissionsDec(PermissionEnum.INVOICES) @@ -71,15 +76,15 @@ export class InvoicesController { return this.invoiceService.payInvoice(paramDto.id, userId); } - // @ApiOperation({ summary: "apply discount on invoice by user" }) - // @Post(":id/apply-discount") - // async applyDiscount(@Param() paramDto: ParamDto, @Body() applyDiscountDto: ApplyDiscountDto, @UserDec("id") userId: string) { - // return this.invoiceService.applyDiscount(paramDto.id, applyDiscountDto, userId); - // } + @ApiOperation({ summary: "apply discount on invoice by user" }) + @Post(":id/apply-discount") + async applyDiscount(@Param() paramDto: ParamDto, @Body() applyDiscountDto: ApplyDiscountDto, @UserDec("id") userId: string) { + return this.discountsService.applyDiscountToInvoice(paramDto.id, applyDiscountDto.code, userId); + } - // @ApiOperation({ summary: "cancel discount by user" }) - // @Post(":id/cancel-discount") - // cancelDiscount(@Param() paramDto: ParamDto, @UserDec("id") userId: string) { - // return this.invoiceService.cancelDiscount(paramDto.id, userId); - // } + @ApiOperation({ summary: "cancel discount by user" }) + @Post(":id/cancel-discount") + cancelDiscount(@Param() paramDto: ParamDto, @UserDec("id") userId: string) { + return this.discountsService.cancelInvoiceDiscount(paramDto.id, userId); + } } diff --git a/src/modules/invoices/invoices.module.ts b/src/modules/invoices/invoices.module.ts index c32db75..0ca6ee6 100755 --- a/src/modules/invoices/invoices.module.ts +++ b/src/modules/invoices/invoices.module.ts @@ -7,24 +7,26 @@ import { InvoiceItem } from "./entities/invoice-item.entity"; import { Invoice } from "./entities/invoice.entity"; import { InvoicesController } from "./invoices.controller"; import { InvoicesService } from "./providers/invoices.service"; +import { InvoiceProcessor } from "./queue/invoice.processor"; +import { InvoiceItemsRepository } from "./repositories/invoice-items.repository"; import { InvoicesRepository } from "./repositories/invoices.repository"; +import { DiscountsModule } from "../discounts/discounts.module"; import { Discount } from "../discounts/entities/discount.entity"; import { UsageDiscount } from "../discounts/entities/usage-discount.entity"; import { DiscountRepository } from "../discounts/repositories/discounts.repository"; import { UsageDiscountRepository } from "../discounts/repositories/usage-discounts.repository"; +import { LoggerModule } from "../logger/logger.module"; 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"; -import { InvoiceItemsRepository } from "./repositories/invoice-items.repository"; -import { LoggerModule } from "../logger/logger.module"; @Module({ imports: [ LoggerModule, BullModule.registerQueue({ name: INVOICE.INVOICE_QUEUE_NAME }), TypeOrmModule.forFeature([Invoice, InvoiceItem, Discount, UsageDiscount]), + DiscountsModule, UsersModule, WalletsModule, UtilsModule,