update: fix

This commit is contained in:
mahyargdz
2025-04-20 12:30:13 +03:30
parent 1c0ef1e1e1
commit b4dd3d2e01
6 changed files with 293 additions and 97 deletions
+1
View File
@@ -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 {
@@ -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)
@@ -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<void> {
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,
@@ -16,7 +16,9 @@ export class DiscountRepository extends Repository<Discount> {
return this.findOne({
where: { id },
relations: {
subscriptionPlans: true,
subscriptionPlans: {
service: true,
},
},
withDeleted: false,
});
@@ -58,4 +60,51 @@ export class DiscountRepository extends Repository<Discount> {
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,
};
}
}
@@ -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 })
@@ -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);