From b4dd3d2e01ab5af7c298183962cfe984876dc5d3 Mon Sep 17 00:00:00 2001 From: mahyargdz Date: Sun, 20 Apr 2025 12:30:13 +0330 Subject: [PATCH] update: fix --- src/common/enums/message.enum.ts | 1 + .../discounts/entities/discount.entity.ts | 2 +- .../discounts/providers/discounts.service.ts | 319 +++++++++++++----- .../repositories/discounts.repository.ts | 51 ++- .../entities/subscription.entity.ts | 6 +- .../providers/subscriptions.service.ts | 11 +- 6 files changed, 293 insertions(+), 97 deletions(-) diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 5b42db8..5f22a8d 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -532,6 +532,7 @@ export const enum DiscountMessage { DIRECT_DISCOUNT_ALREADY_EXISTS = "تخفیف مستقیم قبلا برای سرویس [serviceName] ثبت شده است", UPDATED = "تخفیف با موفقیت بروزرسانی شد", DEACTIVATED = "تخفیف با موفقیت غیرفعال شد", + TARGET_SERVICES_REQUIRED_FOR_DIRECT_DISCOUNT = "برای تخفیف مستقیم باید حداقل یک سرویس انتخاب شود", } export const enum EmailMessage { diff --git a/src/modules/discounts/entities/discount.entity.ts b/src/modules/discounts/entities/discount.entity.ts index 94a2e39..639b36d 100755 --- a/src/modules/discounts/entities/discount.entity.ts +++ b/src/modules/discounts/entities/discount.entity.ts @@ -44,7 +44,7 @@ export class Discount extends BaseEntity { @OneToMany(() => UsageDiscount, (usageDiscount) => usageDiscount.discount) usages: UsageDiscount[]; - @OneToMany(() => SubscriptionPlan, (subscriptionPlan) => subscriptionPlan.directDiscount) + @OneToMany(() => SubscriptionPlan, (subscriptionPlan) => subscriptionPlan.directDiscount, { cascade: ["update"] }) subscriptionPlans: SubscriptionPlan[]; @OneToMany(() => Invoice, (invoice) => invoice.discount) diff --git a/src/modules/discounts/providers/discounts.service.ts b/src/modules/discounts/providers/discounts.service.ts index 5002ec4..a18ecfa 100755 --- a/src/modules/discounts/providers/discounts.service.ts +++ b/src/modules/discounts/providers/discounts.service.ts @@ -7,7 +7,7 @@ import dayjs from "dayjs"; import { Decimal } from "decimal.js"; import { DataSource, QueryRunner } from "typeorm"; -import { DiscountMessage } from "../../../common/enums/message.enum"; +import { CommonMessage, DiscountMessage } from "../../../common/enums/message.enum"; import { SubscriptionPlan } from "../../subscriptions/entities/subscription.entity"; import { SubscriptionsService } from "../../subscriptions/providers/subscriptions.service"; import { DISCOUNT } from "../constants"; @@ -30,6 +30,13 @@ export class DiscountsService { private readonly dataSource: DataSource, ) {} + //====================================== + // PUBLIC METHODS + //====================================== + + /** + * Create a new discount + */ async create(createDiscountDto: CreateDiscountDto) { const queryRunner = this.dataSource.createQueryRunner(); @@ -55,7 +62,10 @@ export class DiscountsService { await queryRunner.release(); } } - //************************************ */ + + /** + * Get all discounts for admin panel with pagination + */ async getDiscountsForAdmin(queryDto: SearchDiscountQueryDto) { const [discounts, count] = await this.discountRepository.findDiscountForAdmin(queryDto); @@ -65,12 +75,30 @@ export class DiscountsService { pagination: true, }; } - //************************************ */ - async getDiscountById(id: string) { - return this.discountRepository.findById(id); - } - //************************************ */ + /** + * Get a discount by ID + */ + async getDiscountById(id: string) { + const discount = await this.discountRepository.findById(id); + if (!discount) throw new BadRequestException(DiscountMessage.NOT_FOUND); + + return { discount }; + } + + /** + * Get a discount with distinct services for client + */ + async getDiscountWithDistinctServicesForClient(id: string) { + const discount = await this.discountRepository.findDiscountWithDistinctServicesForClient(id); + if (!discount) throw new BadRequestException(DiscountMessage.NOT_FOUND); + + return { discount }; + } + + /** + * Toggle discount active status + */ async toggleDiscountStatus(id: string) { const discount = await this.discountRepository.findById(id); if (!discount) throw new BadRequestException(DiscountMessage.NOT_FOUND); @@ -83,7 +111,10 @@ export class DiscountsService { discount, }; } - //************************************ */ + + /** + * Update an existing discount + */ async updateDiscount(id: string, updateDiscountDto: UpdateDiscountDto) { const queryRunner = this.dataSource.createQueryRunner(); @@ -101,23 +132,42 @@ export class DiscountsService { await this.addDiscountDeactivationJob(existingDiscount.id, updateDiscountDto.endDate); } - if (updateDiscountDto.value) existingDiscount.value = updateDiscountDto.value; + // Check if discount is being converted from code-based to direct + const isConvertingToDirectDiscount = + updateDiscountDto.direct === true && existingDiscount.applicationType === DiscountApplicationType.CODE_BASED; - await queryRunner.manager.save({ - ...existingDiscount, + // When converting to direct discount, target services are required + if (isConvertingToDirectDiscount && (!updateDiscountDto.targetServices || updateDiscountDto.targetServices.length === 0)) { + throw new BadRequestException(DiscountMessage.TARGET_SERVICES_REQUIRED_FOR_DIRECT_DISCOUNT); + } + + // Normal direct discount update or first-time conversion to direct discount with services + if (updateDiscountDto.direct && updateDiscountDto.targetServices && updateDiscountDto.targetServices.length > 0) { + await this.updateDirectDiscount(updateDiscountDto, existingDiscount, queryRunner); + } + // When changing from direct to code-based, clear all associated plans + else if (updateDiscountDto.direct === false && existingDiscount.applicationType === DiscountApplicationType.DIRECT) { + await this.clearDirectDiscountFromPlans(existingDiscount, queryRunner); + } + + // Update discount properties directly on existingDiscount to preserve relationships + // This is done AFTER handling subscription plans to prevent overwriting relationship changes + Object.assign(existingDiscount, { ...updateDiscountDto, applicationType: updateDiscountDto.direct ? DiscountApplicationType.DIRECT : DiscountApplicationType.CODE_BASED, }); - if (updateDiscountDto.direct) { - await this.updateDirectDiscount(updateDiscountDto, existingDiscount, queryRunner); - } + // Save the updated discount + await queryRunner.manager.save(this.discountRepository.target, existingDiscount); await queryRunner.commitTransaction(); + // Get the updated discount with all relations + const updatedDiscount = await this.discountRepository.findById(id); + return { message: DiscountMessage.UPDATED, - discount: existingDiscount, + discount: updatedDiscount, }; } catch (error) { await queryRunner.rollbackTransaction(); @@ -126,12 +176,21 @@ export class DiscountsService { await queryRunner.release(); } } - //************************************ */ - async softDeleteDiscount(id: string): Promise { - await this.discountRepository.softDeleteDiscount(id); - } - //************************************ */ + /** + * Soft delete a discount + */ + async softDeleteDiscount(id: string) { + await this.discountRepository.softDeleteDiscount(id); + + return { + message: CommonMessage.DELETED, + }; + } + + /** + * Deactivate a discount and update related subscription plans + */ async deactivateDiscount(id: string, queryRunner: QueryRunner) { const discount = await queryRunner.manager.findOne(this.discountRepository.target, { where: { id, isActive: true }, @@ -143,16 +202,14 @@ export class DiscountsService { if (!discount) throw new BadRequestException(DiscountMessage.NOT_FOUND); discount.isActive = false; - await queryRunner.manager.save(discount); + await queryRunner.manager.save(this.discountRepository.target, discount); const subscriptionPlans = discount.subscriptionPlans; if (discount.applicationType === DiscountApplicationType.DIRECT) { for (const subscriptionPlan of subscriptionPlans) { - subscriptionPlan.directDiscount = undefined; - subscriptionPlan.price = subscriptionPlan.originalPrice - ? new Decimal(subscriptionPlan.originalPrice) - : new Decimal(subscriptionPlan.price); - await queryRunner.manager.save(subscriptionPlan); + subscriptionPlan.directDiscount = null!; + subscriptionPlan.price = new Decimal(subscriptionPlan.originalPrice); + await queryRunner.manager.save(SubscriptionPlan, subscriptionPlan); } } @@ -161,7 +218,14 @@ export class DiscountsService { discount, }; } - //************************************ */ + + //====================================== + // PRIVATE METHODS + //====================================== + + /** + * Create a discount entity and handle code generation or direct discount application + */ private async createDiscount(createDiscountDto: CreateDiscountDto, queryRunner: QueryRunner) { const discount = queryRunner.manager.create(this.discountRepository.target, { ...createDiscountDto, @@ -173,7 +237,7 @@ export class DiscountsService { } discount.value = this.calculateDiscountValue(createDiscountDto); - await queryRunner.manager.save(discount); + await queryRunner.manager.save(this.discountRepository.target, discount); if (createDiscountDto.direct) { await this.applyDirectDiscount(createDiscountDto, discount, queryRunner); @@ -181,7 +245,10 @@ export class DiscountsService { return discount; } - //************************************ */ + + /** + * Apply a direct discount to subscription plans + */ private async applyDirectDiscount(createDiscountDto: CreateDiscountDto, discount: Discount, queryRunner: QueryRunner) { const subscriptionPlans = await this.subscriptionService.getAllServiceSubscriptions( createDiscountDto.targetServices ?? [], @@ -195,16 +262,120 @@ export class DiscountsService { ); } + // Calculate prices for all subscription plans for (const subscriptionPlan of subscriptionPlans) { - subscriptionPlan.directDiscount = discount; - await this.updatePlanPrice(subscriptionPlan, discount); - await queryRunner.manager.save(subscriptionPlan); + if (!subscriptionPlan.originalPrice) { + subscriptionPlan.originalPrice = new Decimal(subscriptionPlan.price); + } + + let discountAmount: Decimal; + if (discount.type === DiscountType.PERCENTAGE) { + discountAmount = new Decimal(subscriptionPlan.originalPrice).mul(discount.value).div(100); + } else { + discountAmount = new Decimal(discount.value); + } + subscriptionPlan.price = new Decimal(subscriptionPlan.originalPrice).sub(discountAmount); } - await queryRunner.manager.save(discount); + // Update the discount and set subscription plans in one operation + discount.subscriptionPlans = subscriptionPlans; + await queryRunner.manager.save(this.discountRepository.target, discount); } - //************************************ */ + /** + * Update a direct discount's subscription plans + */ + private async updateDirectDiscount(updateDiscountDto: UpdateDiscountDto, discount: Discount, queryRunner: QueryRunner) { + // Get all subscription plans for the target services + const subscriptionPlans = await this.subscriptionService.getAllServiceSubscriptions( + updateDiscountDto.targetServices ?? [], + queryRunner, + ); + + console.log(`Found ${subscriptionPlans.length} subscription plans for target services`); + + // Get current plans with direct discount regardless of loaded relations + let currentPlans: SubscriptionPlan[] = []; + if (discount.applicationType === DiscountApplicationType.DIRECT) { + // Find all subscription plans that have this discount directly applied + currentPlans = await queryRunner.manager.find(SubscriptionPlan, { + where: { directDiscount: { id: discount.id } }, + relations: { directDiscount: true, service: true }, + }); + + console.log(`Found ${currentPlans.length} current plans with this discount applied`); + } + + // Remove discount from plans that are no longer targeted + for (const plan of currentPlans) { + if (!subscriptionPlans.some((newPlan) => newPlan.id === plan.id)) { + console.log(`Removing discount from plan ${plan.id}`); + plan.directDiscount = null!; + plan.price = new Decimal(plan.originalPrice); + await queryRunner.manager.save(SubscriptionPlan, plan); + } + } + + // Check for existing discounts on new plans + const plansWithExistingDiscount = subscriptionPlans.filter((plan) => plan.directDiscount && plan.directDiscount.id !== discount.id); + + if (plansWithExistingDiscount.length > 0) { + throw new BadRequestException( + DiscountMessage.DIRECT_DISCOUNT_ALREADY_EXISTS.replace("[serviceName]", plansWithExistingDiscount[0].service.name), + ); + } + + // Calculate prices for all subscription plans + for (const subscriptionPlan of subscriptionPlans) { + if (!subscriptionPlan.originalPrice) { + subscriptionPlan.originalPrice = new Decimal(subscriptionPlan.price); + } + + let discountAmount: Decimal; + if (discount.type === DiscountType.PERCENTAGE) { + discountAmount = new Decimal(subscriptionPlan.originalPrice).mul(discount.value).div(100); + } else { + discountAmount = new Decimal(discount.value); + } + subscriptionPlan.price = new Decimal(subscriptionPlan.originalPrice).sub(discountAmount); + } + + console.log(`Setting ${subscriptionPlans.length} subscription plans on discount ${discount.id}`); + + // Update the discount and set subscription plans in one operation + discount.subscriptionPlans = subscriptionPlans; + + // First save each subscription plan with explicit reference to the discount + for (const plan of subscriptionPlans) { + plan.directDiscount = discount; + await queryRunner.manager.save(SubscriptionPlan, plan); + } + + // Then save the discount with the relationship + await queryRunner.manager.save(this.discountRepository.target, discount); + + return subscriptionPlans; + } + + /** + * Clear direct discount from plans + */ + private async clearDirectDiscountFromPlans(discount: Discount, queryRunner: QueryRunner) { + const subscriptionPlans = await queryRunner.manager.find(SubscriptionPlan, { + where: { directDiscount: { id: discount.id } }, + relations: { directDiscount: true }, + }); + + for (const plan of subscriptionPlans) { + plan.directDiscount = null!; + plan.price = new Decimal(plan.originalPrice); + await queryRunner.manager.save(SubscriptionPlan, plan); + } + } + + /** + * Generate a discount code for code-based discounts + */ private async generateDiscountCode(discount: Discount, queryRunner: QueryRunner) { let attempts = 0; let code: string; @@ -220,28 +391,37 @@ export class DiscountsService { discount.code = code; } - //************************************ */ + /** + * Generate a random discount code + */ + private async generateRandomCode(length: number = 4) { + return randomBytes(length).toString("hex").toUpperCase(); + } + + /** + * Find a discount by code + */ private async findDiscountByCode(code: string, queryRunner: QueryRunner) { return queryRunner.manager.findOne(this.discountRepository.target, { where: { code, isActive: true }, withDeleted: false, }); } - //************************************ */ - - private async generateRandomCode(length: number = 4) { - return randomBytes(length).toString("hex").toUpperCase(); - } - //************************************ */ + /** + * Calculate discount value based on discount type + */ private calculateDiscountValue(createDiscountDto: CreateDiscountDto): number { if (createDiscountDto.type === DiscountType.PERCENTAGE) return createDiscountDto.value; if (createDiscountDto.type === DiscountType.FIXED_AMOUNT) return createDiscountDto.value; throw new BadRequestException(DiscountMessage.INVALID_DISCOUNT_TYPE); } - //************************************ */ + + /** + * Validate discount start and end dates + */ private async validateDates(startDate: string, endDate: string) { if (dayjs(startDate).isAfter(dayjs(endDate))) { throw new BadRequestException(DiscountMessage.START_DATE_MUST_BE_BEFORE_END_DATE); @@ -259,60 +439,19 @@ export class DiscountsService { throw new BadRequestException(DiscountMessage.END_DATE_MUST_BE_AFTER_NOW); } } - //************************************ */ - private async updateDirectDiscount(updateDiscountDto: UpdateDiscountDto, discount: Discount, queryRunner: QueryRunner) { - const subscriptionPlans = await this.subscriptionService.getAllServiceSubscriptions( - updateDiscountDto.targetServices ?? [], - queryRunner, - ); - const currentPlans = await this.subscriptionService.getAllServiceSubscriptions( - discount.subscriptionPlans.map((plan) => plan.service.id), - queryRunner, - ); - - for (const plan of currentPlans) { - if (!subscriptionPlans.some((newPlan) => newPlan.id === plan.id)) { - plan.directDiscount = undefined; - await queryRunner.manager.save(plan); - } - } - - const plansWithExistingDiscount = subscriptionPlans.filter((plan) => plan.directDiscount && plan.directDiscount.id !== discount.id); - - if (plansWithExistingDiscount.length > 0) { - throw new BadRequestException( - DiscountMessage.DIRECT_DISCOUNT_ALREADY_EXISTS.replace("[serviceName]", plansWithExistingDiscount[0].service.name), - ); - } - - for (const subscriptionPlan of subscriptionPlans) { - subscriptionPlan.directDiscount = discount; - await this.updatePlanPrice(subscriptionPlan, discount); - await queryRunner.manager.save(subscriptionPlan); - } - - await queryRunner.manager.save(discount); - } - //************************************ */ - private async updatePlanPrice(subscriptionPlan: SubscriptionPlan, discount: Discount) { - if (!subscriptionPlan.originalPrice) subscriptionPlan.originalPrice = new Decimal(subscriptionPlan.price); - - let discountAmount: Decimal; - if (discount.type === DiscountType.PERCENTAGE) { - discountAmount = subscriptionPlan.originalPrice.mul(discount.value).div(100); - } else { - discountAmount = new Decimal(discount.value); - } - subscriptionPlan.price = subscriptionPlan.originalPrice.sub(discountAmount); - } - //************************************ */ + /** + * Remove discount deactivation job + */ private async removeDiscountDeactivationJob(discountId: string) { const jobs = await this.discountQueue.getJobs(["delayed", "waiting", "active"]); const jobToRemove = jobs.find((job) => job.name === DISCOUNT.DISCOUNT_DEACTIVATE_JOB_NAME && job.data.discountId === discountId); if (jobToRemove) await jobToRemove.remove(); } - //************************************ */ + + /** + * Add discount deactivation job + */ private async addDiscountDeactivationJob(discountId: string, endDate: string) { return this.discountQueue.add( DISCOUNT.DISCOUNT_DEACTIVATE_JOB_NAME, diff --git a/src/modules/discounts/repositories/discounts.repository.ts b/src/modules/discounts/repositories/discounts.repository.ts index 350aec9..5726109 100755 --- a/src/modules/discounts/repositories/discounts.repository.ts +++ b/src/modules/discounts/repositories/discounts.repository.ts @@ -16,7 +16,9 @@ export class DiscountRepository extends Repository { return this.findOne({ where: { id }, relations: { - subscriptionPlans: true, + subscriptionPlans: { + service: true, + }, }, withDeleted: false, }); @@ -58,4 +60,51 @@ export class DiscountRepository extends Repository { async softDeleteDiscount(id: string) { return this.softDelete(id); } + + async findDiscountWithDistinctServicesForClient(id: string) { + // First, get the basic discount information + const discount = await this.findOne({ + where: { id, isActive: true }, + withDeleted: false, + }); + + if (!discount) return null; + + // Get subscription plans with their services + const queryBuilder = this.createQueryBuilder("discount") + .leftJoinAndSelect("discount.subscriptionPlans", "plans") + .leftJoinAndSelect("plans.service", "service") + .where("discount.id = :id", { id }) + .andWhere("discount.isActive = :isActive", { isActive: true }); + + const discountWithPlans = await queryBuilder.getOne(); + + // Get distinct services using raw SQL + const distinctServicesQuery = this.manager.query( + ` + SELECT DISTINCT ON (service.id) + service.id, + service.name, + service.description, + service.icon_url as "iconUrl", + service.is_active as "isActive" + FROM danak_service service + INNER JOIN subscription_plan plan ON plan.service_id = service.id + INNER JOIN discount_subscription_plans dsp ON dsp.subscription_plan_id = plan.id + WHERE dsp.discount_id = $1 + AND service.deleted_at IS NULL + AND service.is_active = true + `, + [id], + ); + + // Combine the results + const [distinctServices] = await Promise.all([distinctServicesQuery]); + + return { + ...discount, + subscriptionPlans: discountWithPlans?.subscriptionPlans || [], + distinctServices, + }; + } } diff --git a/src/modules/subscriptions/entities/subscription.entity.ts b/src/modules/subscriptions/entities/subscription.entity.ts index 0b5bf3a..5c80413 100755 --- a/src/modules/subscriptions/entities/subscription.entity.ts +++ b/src/modules/subscriptions/entities/subscription.entity.ts @@ -20,8 +20,8 @@ export class SubscriptionPlan extends BaseEntity { @Column({ type: "decimal", precision: 16, scale: 2, nullable: false, transformer: new DecimalTransformer() }) price: Decimal; - @Column({ type: "decimal", precision: 16, scale: 2, nullable: true, transformer: new DecimalTransformer() }) - originalPrice?: Decimal; + @Column({ type: "decimal", precision: 16, scale: 2, nullable: false, transformer: new DecimalTransformer() }) + originalPrice: Decimal; @Column({ type: "boolean", default: true }) isActive: boolean; @@ -32,7 +32,7 @@ export class SubscriptionPlan extends BaseEntity { @OneToMany(() => UserSubscription, (userSubscription) => userSubscription.plan) userSubscriptions: UserSubscription[]; - @ManyToOne(() => Discount, (discount) => discount.subscriptionPlans, { nullable: true }) + @ManyToOne(() => Discount, (discount) => discount.subscriptionPlans, { nullable: true, onUpdate: "CASCADE" }) directDiscount?: Discount; @DeleteDateColumn({ nullable: true }) diff --git a/src/modules/subscriptions/providers/subscriptions.service.ts b/src/modules/subscriptions/providers/subscriptions.service.ts index 1e39656..f22e9cb 100755 --- a/src/modules/subscriptions/providers/subscriptions.service.ts +++ b/src/modules/subscriptions/providers/subscriptions.service.ts @@ -37,7 +37,11 @@ export class SubscriptionsService { const danakService = await this.danakServices.findServiceById(createDto.serviceId); if (!danakService) throw new BadRequestException(ServiceMessage.SERVICE_NOT_FOUND_BY_ID); - const subscriptions = createDto.subs.map((sub) => ({ ...sub, service: { id: createDto.serviceId } })); + const subscriptions = createDto.subs.map((sub) => ({ + ...sub, + service: { id: createDto.serviceId }, + originalPrice: new Decimal(sub.price), + })); const subscriptionNames = createDto.subs.map((sub) => sub.name); const existingSubscriptions = await this.subscriptionsPlanRepository.find({ @@ -124,7 +128,10 @@ export class SubscriptionsService { } if (updateDto.duration) subscription.duration = updateDto.duration; - if (updateDto.price) subscription.price = new Decimal(updateDto.price); + if (updateDto.price) { + subscription.price = new Decimal(updateDto.price); + subscription.originalPrice = new Decimal(updateDto.price); + } await this.subscriptionsPlanRepository.save(subscription);