This commit is contained in:
mahyargdz
2025-04-17 09:52:54 +03:30
parent 999ce9cee2
commit f9cb3cf653
12 changed files with 520 additions and 322 deletions
+19 -1
View File
@@ -457,7 +457,7 @@ export const enum InvoiceMessage {
INVOICE_IS_OVERDUE = "صورت حساب منقضی شده است", INVOICE_IS_OVERDUE = "صورت حساب منقضی شده است",
INVOICE_PAID = "صورت حساب با موفقیت پرداخت شد", INVOICE_PAID = "صورت حساب با موفقیت پرداخت شد",
DISCOUNT_APPLIED = "تخفیف با موفقیت اعمال شد", DISCOUNT_APPLIED = "تخفیف با موفقیت اعمال شد",
DISCOUNT_CANCELLED = "تخفیف با موفقیت لغو شد", DISCOUNT_CANCELED = "تخفیف با موفقیت لغو شد",
INVOICE_IS_REJECTED = "صورت حساب قبلا رد شده است", INVOICE_IS_REJECTED = "صورت حساب قبلا رد شده است",
INVOICE_CAN_NOT_PAID = "صورت حساب قابل پرداخت نیست", INVOICE_CAN_NOT_PAID = "صورت حساب قابل پرداخت نیست",
INVALID_DATE = "تاریخ وارد شده معتبر نیست", INVALID_DATE = "تاریخ وارد شده معتبر نیست",
@@ -465,6 +465,11 @@ export const enum InvoiceMessage {
RECURRING_PERIOD_REQUIRED = "دوره تکرار مورد نیاز است", RECURRING_PERIOD_REQUIRED = "دوره تکرار مورد نیاز است",
INVOICE_CAN_NOT_UPDATE = "صورت حساب قابل ویرایش نیست", INVOICE_CAN_NOT_UPDATE = "صورت حساب قابل ویرایش نیست",
INVOICE_UPDATED = "صورت حساب با موفقیت به روز رسانی شد", INVOICE_UPDATED = "صورت حساب با موفقیت به روز رسانی شد",
DISCOUNT_CODE_REQUIRED = "کد تخفیف الزامی است",
DISCOUNT_CODE_MUST_BE_A_STRING = "کد تخفیف باید یک رشته باشد",
DISCOUNT_CODE_LENGTH = "طول کد تخفیف باید بین ۱ تا ۱۰۰ کاراکتر باشد",
ALREADY_HAS_DISCOUNT = "این صورت حساب قبلا دارای تخفیف است",
NO_DISCOUNT = "این صورت حساب دارای تخفیف نیست",
} }
export const enum LearningMessage { export const enum LearningMessage {
@@ -498,6 +503,19 @@ export const enum DiscountMessage {
IS_ACTIVE_MUST_BE_A_BOOLEAN = "وضعیت فعال بودن باید یک بولین باشد", IS_ACTIVE_MUST_BE_A_BOOLEAN = "وضعیت فعال بودن باید یک بولین باشد",
TARGET_PRODUCTS_MUST_BE_AN_ARRAY = "تعداد محصولات باید یک آرایه باشد", TARGET_PRODUCTS_MUST_BE_AN_ARRAY = "تعداد محصولات باید یک آرایه باشد",
EACH_TARGET_PRODUCT_MUST_BE_A_STRING = "هر محصول باید یک رشته باشد", 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 { export const enum EmailMessage {
@@ -385,7 +385,9 @@ export class DanakServicesService {
async getDanakServiceByIdPublic(serviceId: string) { async getDanakServiceByIdPublic(serviceId: string) {
const danakService = await this.danakServicesRepository.getDanakServiceById(serviceId, false); const danakService = await this.danakServicesRepository.getDanakServiceById(serviceId, false);
if (!danakService) throw new BadRequestException(ServiceMessage.SERVICE_NOT_FOUND_BY_ID); if (!danakService) throw new BadRequestException(ServiceMessage.SERVICE_NOT_FOUND_BY_ID);
return { danakService }; const averageRating = await this.danakServiceReviewRepository.getAverageRating(danakService.id);
return { danakService, averageRating };
} }
/******************************************** */ /******************************************** */
@@ -1,18 +1,6 @@
import { ApiProperty } from "@nestjs/swagger"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer"; import { Type } from "class-transformer";
import { import { IsArray, IsBoolean, IsDateString, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, Min, ValidateIf } from "class-validator";
IsArray,
IsBoolean,
IsDateString,
IsEnum,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
Max,
Min,
ValidateIf,
} from "class-validator";
import { DiscountMessage } from "../../../common/enums/message.enum"; import { DiscountMessage } from "../../../common/enums/message.enum";
import { DiscountType } from "../enums/discount-type.enum"; import { DiscountType } from "../enums/discount-type.enum";
@@ -25,35 +13,61 @@ export class CreateDiscountDto {
@IsNotEmpty({ message: DiscountMessage.DISCOUNT_TYPE_REQUIRED }) @IsNotEmpty({ message: DiscountMessage.DISCOUNT_TYPE_REQUIRED })
@IsEnum(DiscountType, { message: DiscountMessage.INVALID_DISCOUNT_TYPE }) @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; 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 }) @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 }) @ApiProperty({ description: "The value of the discount", example: 20 })
value: number; value: number;
@IsDateString({}, { message: DiscountMessage.START_DATE_MUST_BE_A_VALID_DATE_STRING })
@IsNotEmpty({ message: DiscountMessage.START_DATE_IS_REQUIRED }) @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" }) @ApiProperty({ description: "The start date of the discount", example: "2023-06-01T00:00:00Z" })
startDate: string; startDate: string;
@IsDateString({}, { message: DiscountMessage.END_DATE_MUST_BE_A_VALID_DATE_STRING })
@IsNotEmpty({ message: DiscountMessage.END_DATE_IS_REQUIRED }) @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" }) @ApiProperty({ description: "The end date of the discount", example: "2023-06-30T23:59:59Z" })
endDate: string; endDate: string;
@IsBoolean({ message: DiscountMessage.IS_ACTIVE_MUST_BE_A_BOOLEAN })
@IsOptional() @IsOptional()
@IsBoolean({ message: DiscountMessage.IS_ACTIVE_MUST_BE_A_BOOLEAN })
@ApiProperty({ description: "Indicates if the discount is active", example: true }) @ApiProperty({ description: "Indicates if the discount is active", example: true })
isActive?: boolean; isActive?: boolean;
@IsArray({ message: "Target products must be an array" })
@IsOptional() @IsOptional()
@ValidateIf((o) => o.targetProducts !== null && o.targetProducts !== undefined) @IsNumber({}, { message: "Max usage must be a number" })
@IsString({ each: true, message: "Each target product must be a string" }) @Min(0, { message: "Max usage must be at least 0" })
@ApiProperty({ description: "List of target product IDs", example: ["prod-1", "prod-2"] }) @Type(() => Number)
targetProducts?: string[]; @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[];
} }
@@ -1,44 +1,5 @@
import { Type } from "class-transformer"; import { PartialType } from "@nestjs/swagger";
import { IsArray, IsBoolean, IsDateString, IsEnum, IsNumber, IsOptional, IsString, Max, Min, ValidateIf } from "class-validator";
import { DiscountStatus } from "../enums/discount-status.enum"; import { CreateDiscountDto } from "./create-discount.dto";
import { DiscountType } from "../enums/discount-type.enum";
export class UpdateDiscountDto { export class UpdateDiscountDto extends PartialType(CreateDiscountDto) {}
@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[];
}
+29 -73
View File
@@ -1,77 +1,33 @@
// import { Body, Controller, Post } from "@nestjs/common"; import { Body, Controller, Get, Param, Post } from "@nestjs/common";
// import { ApiOperation, ApiResponse } from "@nestjs/swagger"; import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
// import { CreateDiscountDto } from "./DTO/create-discount.dto"; import { CreateDiscountDto } from "./DTO/create-discount.dto";
// import { Discount } from "./entities/discount.entity"; import { DiscountsService } from "./providers/discounts.service";
// import { DiscountsService } from "./providers/discounts.service"; import { AdminRoute } from "../../common/decorators/admin.decorator";
// import { AdminRoute } from "../../common/decorators/admin.decorator"; import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
// import { AuthGuards } from "../../common/decorators/auth-guard.decorator"; import { PermissionsDec } from "../../common/decorators/permission.decorator";
// import { PermissionsDec } from "../../common/decorators/permission.decorator"; import { PermissionEnum } from "../users/enums/permission.enum";
// import { PermissionEnum } from "../users/enums/permission.enum";
// @Controller("discounts") @ApiTags("Discounts")
// @AuthGuards() @Controller("discounts")
// export class DiscountsController { @AuthGuards()
// constructor(private readonly discountsService: DiscountsService) {} export class DiscountsController {
constructor(private readonly discountsService: DiscountsService) {}
// @ApiOperation({ summary: "Create a new discount" }) @ApiOperation({ summary: "Create a new discount" })
// @AdminRoute() @AdminRoute()
// @PermissionsDec(PermissionEnum.DISCOUNTS) @PermissionsDec(PermissionEnum.DISCOUNTS)
// @Post() @Post()
// @ApiResponse({ status: 201, description: "Discount created successfully" }) @ApiResponse({ status: 201, description: "Discount created successfully" })
// create(@Body() createDiscountDto: CreateDiscountDto): Promise<Discount> { create(@Body() createDiscountDto: CreateDiscountDto) {
// return this.discountsService.create(createDiscountDto); return this.discountsService.create(createDiscountDto);
// } }
// // @AdminRoute() @ApiOperation({ summary: "Get discount by ID" })
// // @PermissionsDec(PermissionEnum.DISCOUNTS) @AdminRoute()
// // @Get() @PermissionsDec(PermissionEnum.DISCOUNTS)
// // @ApiOperation({ summary: "Get all discounts" }) @Get(":id")
// // findAll(@Query() query: any) {} findOne(@Param("id") id: string) {
return this.discountsService.findById(id);
// // @AdminRoute() }
// // @PermissionsDec(PermissionEnum.DISCOUNTS) }
// // @Get(":id")
// // @ApiOperation({ summary: "Get discount by ID" })
// // findOne(@Param("id") id: string): Promise<Discount> {
// // 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<string[]> {
// // return this.discountsService.getProductsForDiscount(id);
// // }
// // @AdminRoute()
// // @PermissionsDec(PermissionEnum.DISCOUNTS)
// // @Put(":id")
// // @ApiOperation({ summary: "Update discount" })
// // update(@Param("id") id: string, @Body() updateDiscountDto: UpdateDiscountDto): Promise<Discount> {
// // 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<Discount> {
// // return this.discountsService.toggleActive(id, toggleActiveDto.isActive);
// // }
// // @AdminRoute()
// // @PermissionsDec(PermissionEnum.DISCOUNTS)
// // @Delete(":id")
// // @ApiOperation({ summary: "Delete discount" })
// // remove(@Param("id") id: string): Promise<void> {
// // return this.discountsService.delete(id);
// // }
// // @Post("validate")
// // @ApiOperation({ summary: "Validate discount code" })
// // validateDiscount(@Body() validateDiscountDto: ValidateDiscountDto, @UserDec() user: User): Promise<Discount> {
// // return this.discountsService.validateDiscount(validateDiscountDto.code, user, validateDiscountDto.productId);
// // }
// }
+9 -8
View File
@@ -1,20 +1,21 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm"; import { TypeOrmModule } from "@nestjs/typeorm";
// import { DiscountsController } from "./discounts.controller"; import { DiscountsController } from "./discounts.controller";
import { UsersModule } from "../users/users.module"; import { UsersModule } from "../users/users.module";
import { DiscountService } from "./entities/discount-product.entity"; import { DiscountService } from "./entities/discount-product.entity";
import { Discount } from "./entities/discount.entity"; import { Discount } from "./entities/discount.entity";
import { UsageDiscount } from "./entities/usage-discount.entity"; import { UsageDiscount } from "./entities/usage-discount.entity";
// import { DiscountsService } from "./providers/discounts.service"; import { DiscountsService } from "./providers/discounts.service";
// import { DiscountServicesRepository } from "./repositories/discount-services.repository"; import { DiscountServicesRepository } from "./repositories/discount-services.repository";
// import { DiscountRepository } from "./repositories/discounts.repository"; import { DiscountRepository } from "./repositories/discounts.repository";
// import { UsageDiscountRepository } from "./repositories/usage-discounts.repository"; import { UsageDiscountRepository } from "./repositories/usage-discounts.repository";
import { DiscountValidator } from "./utils/discount-validator";
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Discount, UsageDiscount, DiscountService]), UsersModule], imports: [TypeOrmModule.forFeature([Discount, UsageDiscount, DiscountService]), UsersModule],
// controllers: [DiscountsController], controllers: [DiscountsController],
// providers: [DiscountsService, DiscountRepository, UsageDiscountRepository, DiscountServicesRepository], providers: [DiscountsService, DiscountRepository, UsageDiscountRepository, DiscountServicesRepository, DiscountValidator],
// exports: [DiscountsService], exports: [DiscountsService, DiscountValidator],
}) })
export class DiscountsModule {} export class DiscountsModule {}
@@ -32,6 +32,15 @@ export class Discount extends BaseEntity {
@Column({ type: "boolean", default: true }) @Column({ type: "boolean", default: true })
isActive: boolean; 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 }) @OneToMany(() => DiscountService, (discountService) => discountService.discount, { cascade: true })
discountServices: DiscountService[]; discountServices: DiscountService[];
@@ -1,194 +1,326 @@
// import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; import { randomBytes } from "node:crypto";
// // eslint-disable-next-line import/no-named-as-default
// import Decimal from "decimal.js";
// import { User } from "../../users/entities/user.entity"; import { BadRequestException, Injectable } from "@nestjs/common";
// import { CreateDiscountDto } from "../DTO/create-discount.dto"; import dayjs from "dayjs";
// import { UpdateDiscountDto } from "../DTO/update-discount.dto"; // eslint-disable-next-line import/no-named-as-default
// import { Discount } from "../entities/discount.entity"; import Decimal from "decimal.js";
// import { DiscountType } from "../enums/discount-type.enum"; 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 { DiscountServicesRepository } from "../repositories/discount-services.repository";
// import { DiscountRepository } from "../repositories/discounts.repository"; import { DiscountRepository } from "../repositories/discounts.repository";
// import { UsageDiscountRepository } from "../repositories/usage-discounts.repository"; import { UsageDiscountRepository } from "../repositories/usage-discounts.repository";
import { DiscountValidator } from "../utils/discount-validator";
// @Injectable() @Injectable()
// export class DiscountsService { export class DiscountsService {
// constructor( constructor(
// private readonly discountRepository: DiscountRepository, private readonly discountRepository: DiscountRepository,
// private readonly discountServicesRepository: DiscountServicesRepository, // private readonly discountServicesRepository: DiscountServicesRepository,
// private readonly usageDiscountRepository: UsageDiscountRepository, private readonly usageDiscountRepository: UsageDiscountRepository,
// ) {} private readonly dataSource: DataSource,
private readonly discountValidator: DiscountValidator,
) {}
// async create(createDiscountDto: CreateDiscountDto) { async create(createDiscountDto: CreateDiscountDto) {
// const existingDiscount = await this.discountRepository.findByCode(createDiscountDto.title); const queryRunner = this.dataSource.createQueryRunner();
// if (existingDiscount) { const { direct } = createDiscountDto;
// throw new BadRequestException(DISCOUNT_ERROR_MESSAGES.ALREADY_EXISTS);
// }
// // Create the discount without product relations try {
// const { targetProducts, ...discountData } = createDiscountDto; await queryRunner.connect();
await queryRunner.startTransaction();
// const discount = await this.discountRepository.create({ const discount = queryRunner.manager.create(this.discountRepository.target, createDiscountDto);
// ...discountData,
// startDate: new Date(createDiscountDto.startDate),
// endDate: new Date(createDiscountDto.endDate),
// status: createDiscountDto.status || DiscountStatus.ACTIVE,
// isActive: createDiscountDto.isActive !== undefined ? createDiscountDto.isActive : true,
// });
// // Add product relations if provided if (!direct) {
// if (targetProducts && targetProducts.length > 0) { const code = await this.generateDiscountCode();
// await this.discountServicesRepository.createMany(discount.id, targetProducts); discount.code = code;
// } }
// // Fetch the complete discount with relations await queryRunner.commitTransaction();
// const fullDiscount = await this.discountRepository.findById(discount.id);
// if (!fullDiscount) {
// throw new NotFoundException(DISCOUNT_ERROR_MESSAGES.NOT_FOUND);
// }
// return fullDiscount;
// }
// async findAll(options?: any): Promise<[Discount[], number]> { return {
// return this.discountRepository.findAll(options); message: DiscountMessage.CREATED,
// } };
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
// async findById(id: string): Promise<Discount> { async findById(id: string) {
// const discount = await this.discountRepository.findById(id); const discount = await this.discountRepository.findById(id);
// if (!discount) {
// throw new NotFoundException(DISCOUNT_ERROR_MESSAGES.NOT_FOUND);
// }
// return discount;
// }
// async update(id: string, updateDiscountDto: UpdateDiscountDto): Promise<Discount> { if (!discount) throw new BadRequestException(DiscountMessage.NOT_FOUND);
// // Validate discount exists
// await this.findById(id);
// // Handle product relations separately return { discount };
// const { targetProducts, startDate: startDateStr, endDate: endDateStr, ...otherFields } = updateDiscountDto; }
// // Initialize update data with other fields async validateDiscountCode(code: string, userId: string, invoice: Invoice, queryRunner: QueryRunner) {
// const updateData: Partial<Discount> = { ...otherFields }; // 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 (!discount) {
// if (startDateStr) { throw new BadRequestException(DiscountMessage.INVALID_CODE);
// updateData.startDate = new Date(startDateStr); }
// }
// if (endDateStr) { // Check if the discount is currently active (time period)
// updateData.endDate = new Date(endDateStr); const now = dayjs();
// } if (now.isBefore(dayjs(discount.startDate)) || now.isAfter(dayjs(discount.endDate))) {
throw new BadRequestException(DiscountMessage.EXPIRED);
}
// // Update the discount entity // Check if the discount has reached its maximum usage
// const updatedDiscount = await this.discountRepository.update(id, updateData); if (discount.maxUsage !== null && discount.usedCount >= discount.maxUsage) {
// if (!updatedDiscount) { throw new BadRequestException(DiscountMessage.MAX_USAGE_REACHED);
// throw new NotFoundException(DISCOUNT_ERROR_MESSAGES.NOT_FOUND); }
// }
// // Handle product relations if provided // Check if the user has reached their maximum usage for this discount
// if (targetProducts !== undefined) { if (discount.maxUsagePerUser !== null) {
// // Remove existing relations const userUsageCount = await this.usageDiscountRepository.countByUserAndDiscount(userId, discount.id);
// await this.discountServicesRepository.deleteByDiscountId(id); if (userUsageCount >= discount.maxUsagePerUser) {
throw new BadRequestException(DiscountMessage.MAX_USER_USAGE_REACHED);
}
}
// // Add new relations if not empty // Check if the discount applies to the items in the invoice
// if (targetProducts.length > 0) { if (!this.discountValidator.validateItemsForDiscount(invoice, discount)) {
// await this.discountServicesRepository.createMany(id, targetProducts); throw new BadRequestException(DiscountMessage.NOT_APPLICABLE);
// } }
// }
// // Return the updated discount with relations return discount;
// return this.findById(id); }
// }
// async delete(id: string): Promise<void> { /**
// const discount = await this.findById(id); * Applies a discount code to an invoice
// await this.discountRepository.delete(discount.id); * @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<Discount> { try {
// await this.findById(id); // Get the invoice
// const updatedDiscount = await this.discountRepository.update(id, { isActive }); const invoice = await queryRunner.manager.findOne(Invoice, {
// if (!updatedDiscount) { where: {
// throw new NotFoundException(DISCOUNT_ERROR_MESSAGES.NOT_FOUND); id: invoiceId,
// } user: { id: userId },
// return updatedDiscount; status: InvoiceStatus.PENDING,
// } },
relations: ["items", "items.subscriptionPlan", "items.subscriptionPlan.plan", "items.subscriptionPlan.plan.service"],
});
// async validateDiscount(code: string, user: User, productId?: string): Promise<Discount> { if (!invoice) {
// const discount = await this.discountRepository.findValidByCode(code); throw new BadRequestException(InvoiceMessage.NOT_FOUND_BY_ID);
}
// if (!discount) { // Check if the invoice already has a discount
// throw new BadRequestException(DISCOUNT_ERROR_MESSAGES.INVALID); if (invoice.discount) {
// } throw new BadRequestException(InvoiceMessage.ALREADY_HAS_DISCOUNT);
}
// // Check if user has already used this discount // Validate the discount code
// const usageCount = await this.usageDiscountRepository.countByUserAndDiscount(user.id, discount.id); const discount = await this.validateDiscountCode(code, userId, invoice, queryRunner);
// if (usageCount > 0) {
// throw new BadRequestException(DISCOUNT_ERROR_MESSAGES.ALREADY_USED);
// }
// // Check if product is valid for this discount // Calculate the discount amount
// if (productId && discount.discountServices && discount.discountServices.length > 0) { const discountAmount = this.discountValidator.calculateDiscountAmount(invoice, discount);
// const hasProduct = await this.discountServicesRepository.hasProductForDiscount(discount.id, productId);
// if (!hasProduct) {
// throw new BadRequestException(DISCOUNT_ERROR_MESSAGES.INVALID_PRODUCT);
// }
// }
// return discount; // Calculate the new total price
// } const newTotalPrice = this.discountValidator.calculateDiscountedTotalPrice(invoice, discountAmount);
// async applyDiscount(amount: number, discountId: string, productId?: string): Promise<number> { // Update the invoice
// const discount = await this.findById(discountId); invoice.discount = discount;
invoice.totalPrice = new Decimal(newTotalPrice);
// if (!discount.isValid) { await queryRunner.manager.save(Invoice, invoice);
// throw new BadRequestException(DISCOUNT_ERROR_MESSAGES.INVALID);
// }
// // Check if product is valid for this discount // Record the discount usage
// if (productId && discount.discountServices && discount.discountServices.length > 0) { const usageDiscount = queryRunner.manager.create(UsageDiscount, {
// const hasProduct = await this.discountServicesRepository.hasProductForDiscount(discount.id, productId); discount,
// if (!hasProduct) { user: { id: userId },
// throw new BadRequestException(DISCOUNT_ERROR_MESSAGES.INVALID_PRODUCT); appliedAmount: discountAmount,
// } usedAt: new Date(),
// } });
// let discountAmount = 0; await queryRunner.manager.save(UsageDiscount, usageDiscount);
// if (discount.type === DiscountType.PERCENTAGE) { // Increment the discount usage count
// discountAmount = (amount * discount.value) / 100; discount.usedCount += 1;
// } else if (discount.type === DiscountType.FIXED_AMOUNT) { await queryRunner.manager.save(Discount, discount);
// discountAmount = discount.value;
// }
// // Ensure discount doesn't exceed the original amount await queryRunner.commitTransaction();
// if (discountAmount > amount) {
// discountAmount = amount;
// }
// 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<void> { /**
// 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 try {
// await this.discountRepository.update(discount.id, { // Get the invoice
// usedCount: discount.usedCount + 1, const invoice = await queryRunner.manager.findOne(Invoice, {
// }); where: {
id: invoiceId,
user: { id: userId },
status: InvoiceStatus.PENDING,
},
relations: ["discount", "items"],
});
// // Record usage if (!invoice) {
// await this.usageDiscountRepository.create({ throw new BadRequestException(InvoiceMessage.NOT_FOUND_BY_ID);
// user: { id: userId } as User, }
// discount: { id: discountId } as Discount,
// appliedAmount: new Decimal(appliedAmount),
// usedAt: new Date(),
// });
// }
// async getProductsForDiscount(discountId: string): Promise<string[]> { // Check if the invoice has a discount
// const discountServices = await this.discountServicesRepository.findByDiscountId(discountId); if (!invoice.discount) {
// return discountServices.map((service) => service.danakService.id); 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;
}
}
@@ -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();
}
}
@@ -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;
}
+16 -11
View File
@@ -1,6 +1,7 @@
import { Body, Controller, Get, Param, Patch, Post, Query } from "@nestjs/common"; import { Body, Controller, Get, Param, Patch, Post, Query } from "@nestjs/common";
import { ApiOperation } from "@nestjs/swagger"; import { ApiOperation } from "@nestjs/swagger";
import { ApplyDiscountDto } from "./DTO/apply-discount.dto";
import { CreateInvoiceDto } from "./DTO/create-invoice.dto"; import { CreateInvoiceDto } from "./DTO/create-invoice.dto";
import { InvoicesSearchQueryDto, UserInvoicesSearchQueryDto } from "./DTO/invoices-search-query.dto"; import { InvoicesSearchQueryDto, UserInvoicesSearchQueryDto } from "./DTO/invoices-search-query.dto";
import { UpdateInvoiceDto } from "./DTO/update-invoice.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 { UserDec } from "../../common/decorators/user.decorator";
import { ParamDto } from "../../common/DTO/param.dto"; import { ParamDto } from "../../common/DTO/param.dto";
import { VerifyOtpWithUserId } from "../auth/DTO/verify-otp.dto"; import { VerifyOtpWithUserId } from "../auth/DTO/verify-otp.dto";
import { DiscountsService } from "../discounts/providers/discounts.service";
import { PermissionEnum } from "../users/enums/permission.enum"; import { PermissionEnum } from "../users/enums/permission.enum";
@Controller("invoices") @Controller("invoices")
@AuthGuards() @AuthGuards()
export class InvoicesController { export class InvoicesController {
constructor(private readonly invoiceService: InvoicesService) {} constructor(
private readonly invoiceService: InvoicesService,
private readonly discountsService: DiscountsService,
) {}
@ApiOperation({ summary: "create an invoice ==> admin route" }) @ApiOperation({ summary: "create an invoice ==> admin route" })
@PermissionsDec(PermissionEnum.INVOICES) @PermissionsDec(PermissionEnum.INVOICES)
@@ -71,15 +76,15 @@ export class InvoicesController {
return this.invoiceService.payInvoice(paramDto.id, userId); return this.invoiceService.payInvoice(paramDto.id, userId);
} }
// @ApiOperation({ summary: "apply discount on invoice by user" }) @ApiOperation({ summary: "apply discount on invoice by user" })
// @Post(":id/apply-discount") @Post(":id/apply-discount")
// async applyDiscount(@Param() paramDto: ParamDto, @Body() applyDiscountDto: ApplyDiscountDto, @UserDec("id") userId: string) { async applyDiscount(@Param() paramDto: ParamDto, @Body() applyDiscountDto: ApplyDiscountDto, @UserDec("id") userId: string) {
// return this.invoiceService.applyDiscount(paramDto.id, applyDiscountDto, userId); return this.discountsService.applyDiscountToInvoice(paramDto.id, applyDiscountDto.code, userId);
// } }
// @ApiOperation({ summary: "cancel discount by user" }) @ApiOperation({ summary: "cancel discount by user" })
// @Post(":id/cancel-discount") @Post(":id/cancel-discount")
// cancelDiscount(@Param() paramDto: ParamDto, @UserDec("id") userId: string) { cancelDiscount(@Param() paramDto: ParamDto, @UserDec("id") userId: string) {
// return this.invoiceService.cancelDiscount(paramDto.id, userId); return this.discountsService.cancelInvoiceDiscount(paramDto.id, userId);
// } }
} }
+5 -3
View File
@@ -7,24 +7,26 @@ import { InvoiceItem } from "./entities/invoice-item.entity";
import { Invoice } from "./entities/invoice.entity"; import { Invoice } from "./entities/invoice.entity";
import { InvoicesController } from "./invoices.controller"; import { InvoicesController } from "./invoices.controller";
import { InvoicesService } from "./providers/invoices.service"; 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 { InvoicesRepository } from "./repositories/invoices.repository";
import { DiscountsModule } from "../discounts/discounts.module";
import { Discount } from "../discounts/entities/discount.entity"; import { Discount } from "../discounts/entities/discount.entity";
import { UsageDiscount } from "../discounts/entities/usage-discount.entity"; import { UsageDiscount } from "../discounts/entities/usage-discount.entity";
import { DiscountRepository } from "../discounts/repositories/discounts.repository"; import { DiscountRepository } from "../discounts/repositories/discounts.repository";
import { UsageDiscountRepository } from "../discounts/repositories/usage-discounts.repository"; import { UsageDiscountRepository } from "../discounts/repositories/usage-discounts.repository";
import { LoggerModule } from "../logger/logger.module";
import { NotificationModule } from "../notifications/notifications.module"; import { NotificationModule } from "../notifications/notifications.module";
import { UsersModule } from "../users/users.module"; import { UsersModule } from "../users/users.module";
import { UtilsModule } from "../utils/utils.module"; import { UtilsModule } from "../utils/utils.module";
import { WalletsModule } from "../wallets/wallets.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({ @Module({
imports: [ imports: [
LoggerModule, LoggerModule,
BullModule.registerQueue({ name: INVOICE.INVOICE_QUEUE_NAME }), BullModule.registerQueue({ name: INVOICE.INVOICE_QUEUE_NAME }),
TypeOrmModule.forFeature([Invoice, InvoiceItem, Discount, UsageDiscount]), TypeOrmModule.forFeature([Invoice, InvoiceItem, Discount, UsageDiscount]),
DiscountsModule,
UsersModule, UsersModule,
WalletsModule, WalletsModule,
UtilsModule, UtilsModule,