import { randomBytes } from "node:crypto"; import { BadRequestException, Injectable } from "@nestjs/common"; import dayjs from "dayjs"; import { Decimal } from "decimal.js"; import { DataSource, QueryRunner } from "typeorm"; import { DiscountMessage } from "../../../common/enums/message.enum"; import { SubscriptionPlan } from "../../subscriptions/entities/subscription.entity"; import { SubscriptionsService } from "../../subscriptions/providers/subscriptions.service"; import { CreateDiscountDto } from "../DTO/create-discount.dto"; import { SearchDiscountQueryDto } from "../DTO/search-discount-query.dto"; import { UpdateDiscountDto } from "../DTO/update-discount.dto"; import { Discount } from "../entities/discount.entity"; import { DiscountApplicationType } from "../enums/discount-application-type.enum"; import { DiscountType } from "../enums/discount-type.enum"; import { DiscountRepository } from "../repositories/discounts.repository"; @Injectable() export class DiscountsService { private MAX_ATTEMPTS = 10; constructor( private readonly discountRepository: DiscountRepository, private readonly subscriptionService: SubscriptionsService, private readonly dataSource: DataSource, ) {} async create(createDiscountDto: CreateDiscountDto) { const queryRunner = this.dataSource.createQueryRunner(); try { await queryRunner.connect(); await queryRunner.startTransaction(); await this.validateDates(createDiscountDto.startDate, createDiscountDto.endDate); const discount = await this.createDiscount(createDiscountDto, queryRunner); await queryRunner.commitTransaction(); return { message: DiscountMessage.CREATED, discount, }; } catch (error) { await queryRunner.rollbackTransaction(); throw error; } finally { await queryRunner.release(); } } //************************************ */ async getDiscountsForAdmin(queryDto: SearchDiscountQueryDto) { const [discounts, count] = await this.discountRepository.findDiscountForAdmin(queryDto); return { discounts, count, pagination: true, }; } //************************************ */ async getDiscountById(id: string) { return this.discountRepository.findById(id); } //************************************ */ async updateDiscount(id: string, updateDiscountDto: UpdateDiscountDto) { const queryRunner = this.dataSource.createQueryRunner(); try { await queryRunner.connect(); await queryRunner.startTransaction(); const existingDiscount = await this.discountRepository.findById(id); if (!existingDiscount) throw new BadRequestException(DiscountMessage.NOT_FOUND); if (updateDiscountDto.startDate && updateDiscountDto.endDate) await this.validateDates(updateDiscountDto.startDate, updateDiscountDto.endDate); if (updateDiscountDto.value) existingDiscount.value = updateDiscountDto.value; await queryRunner.manager.save({ ...existingDiscount, ...updateDiscountDto, applicationType: updateDiscountDto.direct ? DiscountApplicationType.DIRECT : DiscountApplicationType.CODE_BASED, }); if (updateDiscountDto.direct) { await this.updateDirectDiscount(updateDiscountDto, existingDiscount, queryRunner); } await queryRunner.commitTransaction(); return { message: DiscountMessage.UPDATED, discount: existingDiscount, }; } catch (error) { await queryRunner.rollbackTransaction(); throw error; } finally { await queryRunner.release(); } } //************************************ */ async softDeleteDiscount(id: string): Promise { await this.discountRepository.softDeleteDiscount(id); } //************************************ */ private async createDiscount(createDiscountDto: CreateDiscountDto, queryRunner: QueryRunner) { const discount = queryRunner.manager.create(this.discountRepository.target, { ...createDiscountDto, applicationType: createDiscountDto.direct ? DiscountApplicationType.DIRECT : DiscountApplicationType.CODE_BASED, }); if (!createDiscountDto.direct) { await this.generateDiscountCode(discount, queryRunner); } discount.value = this.calculateDiscountValue(createDiscountDto); await queryRunner.manager.save(discount); if (createDiscountDto.direct) { await this.applyDirectDiscount(createDiscountDto, discount, queryRunner); } return discount; } //************************************ */ private async applyDirectDiscount(createDiscountDto: CreateDiscountDto, discount: Discount, queryRunner: QueryRunner) { const subscriptionPlans = await this.subscriptionService.getAllServiceSubscriptions( createDiscountDto.targetServices ?? [], queryRunner, ); const plansWithExistingDiscount = subscriptionPlans.filter((plan) => plan.directDiscount); 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 generateDiscountCode(discount: Discount, queryRunner: QueryRunner) { let attempts = 0; let code: string; let existCode; do { if (attempts >= this.MAX_ATTEMPTS) throw new BadRequestException(DiscountMessage.CODE_GENERATION_FAILED); code = await this.generateRandomCode(); existCode = await this.findDiscountByCode(code, queryRunner); attempts++; } while (existCode && attempts < this.MAX_ATTEMPTS); discount.code = code; } //************************************ */ private async findDiscountByCode(code: string, queryRunner: QueryRunner) { return queryRunner.manager.findOne(this.discountRepository.target, { where: { code }, withDeleted: false, }); } //************************************ */ private async generateRandomCode(length: number = 4) { return randomBytes(length).toString("hex").toUpperCase(); } //************************************ */ 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); } //************************************ */ private async validateDates(startDate: string, endDate: string) { if (dayjs(startDate).isAfter(dayjs(endDate))) { throw new BadRequestException(DiscountMessage.START_DATE_MUST_BE_BEFORE_END_DATE); } if (dayjs(startDate).isBefore(dayjs().subtract(1, "day"))) { throw new BadRequestException(DiscountMessage.START_DATE_MUST_BE_AFTER_NOW); } if (dayjs(startDate).isSame(dayjs(endDate))) { throw new BadRequestException(DiscountMessage.START_DATE_MUST_BE_BEFORE_END_DATE); } if (dayjs(endDate).isBefore(dayjs())) { 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); } }