feat: implement support plan upgrade functionality with invoice creation

This commit is contained in:
mahyargdz
2025-05-06 12:31:55 +03:30
parent 1ea3195f4f
commit 8b4267807b
11 changed files with 153 additions and 19 deletions
+3 -3
View File
@@ -7,13 +7,13 @@ import { ConfigModule } from "@nestjs/config";
import { ThrottlerModule } from "@nestjs/throttler";
import { TypeOrmModule } from "@nestjs/typeorm";
import { MailerModule } from "@nestjs-modules/mailer";
import { TelegrafModule } from "nestjs-telegraf";
// import { TelegrafModule } from "nestjs-telegraf";
import { bullMqConfig } from "./configs/bullmq.config";
import { cacheConfig } from "./configs/cache.config";
import { mailerConfig } from "./configs/mailer.config";
import { rateLimitConfig } from "./configs/rateLimit.config";
import { telegrafConfig } from "./configs/telegraf.config";
// import { telegrafConfig } from "./configs/telegraf.config";
import { databaseConfigs } from "./configs/typeorm.config";
import { HTTPLogger } from "./core/middlewares/logger.middleware";
import { AddressModule } from "./modules/address/address.module";
@@ -43,7 +43,7 @@ import { WalletsModule } from "./modules/wallets/wallets.module";
import { MonitoringModule } from "./monitoring/monitoring.module";
@Module({
imports: [
TelegrafModule.forRootAsync(telegrafConfig()),
// TelegrafModule.forRootAsync(telegrafConfig()),
MailerModule.forRootAsync(mailerConfig()),
BullModule.forRootAsync(bullMqConfig()),
ThrottlerModule.forRootAsync(rateLimitConfig()),
+9 -3
View File
@@ -806,7 +806,13 @@ export const enum SupportPlanMessage {
SUPPORT_PLAN_NOT_FOUND = "پلن پشتیبانی مورد نظر یافت نشد",
SUPPORT_PLAN_DELETED = "پلن پشتیبانی با موفقیت حذف شد",
SUPPORT_PLAN_UPDATED = "پلن پشتیبانی با موفقیت به روز رسانی شد",
SUPPORT_PLAN_SUBSCRIBED = "شما با موفقیت در پلن پشتیبانی عضو شدید",
SUPPORT_PLAN_ALREADY_SUBSCRIBED_OR_INVOICE_EXIST = "شما قبلا در پلن پشتیبانی عضو شدید یا فاکتوری با این پلن وجود دارد",
USER_ALREADY_HAS_SUPPORT_PLAN = "شما قبلا پلن پشتیبانی خریداری کردید",
USER_HAS_NO_SUPPORT_PLAN = "کاربر پلن پشتیبانی فعال ندارد",
SUPPORT_PLAN_ALREADY_SUBSCRIBED_OR_INVOICE_EXIST = " کاربر قبلا اشتراک ثبت کرده یا فاکتور آن موجود است و پرداخت نشده است",
USER_ALREADY_HAS_SUPPORT_PLAN = "کاربر قبلا پلن پشتیبانی دارد",
SUPPORT_PLAN_SUBSCRIBED = " اشتراک با موفقیت ثبت شد",
NEW_PLAN_MUST_BE_MORE_EXPENSIVE = "پلن جدید باید گران‌تر از پلن فعلی باشد",
UPGRADE_PRICE_CALCULATION_FAILED = "محاسبه قیمت ارتقا با خطا مواجه شد",
SUPPORT_PLAN_UPGRADE_INITIATED_SUCCESSFULLY = "ارتقا پلن پشتیبانی با موفقیت انجام شد",
SUPPORT_PLAN_ALREADY_SUBSCRIBED = "کاربر قبلا اشتراک ثبت کرده است",
DOWN_GRADE_NOT_ALLOWED = "امکان تغییر به پلن پایین‌تر وجود ندارد",
}
+1 -1
View File
@@ -13,7 +13,7 @@ export function databaseConfigs(): TypeOrmModuleAsyncOptions {
username: configService.getOrThrow<string>("DB_USER"),
password: configService.getOrThrow<string>("DB_PASS"),
autoLoadEntities: true,
synchronize: configService.getOrThrow<string>("NODE_ENV") == "production" ? false : true,
synchronize: configService.getOrThrow<string>("NODE_ENV") == "production" ? false : false,
logging: configService.getOrThrow<string>("NODE_ENV") == "production" ? false : true,
migrationsTableName: "typeorm_migrations",
migrationsRun: false,
+1
View File
@@ -16,6 +16,7 @@ import { SSOTokenValidateDTO } from "./DTO/requests/sso-token-validate.dto";
import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
import { UserDec } from "../../common/decorators/user.decorator";
import { User } from "../users/entities/user.entity";
@ApiTags("Auth")
@Controller("auth")
@Throttle({ default: { limit: AUTH_THROTTLE_LIMIT, ttl: AUTH_THROTTLE_TTL } })
@@ -383,9 +383,16 @@ export class InvoicesService {
return invoice;
}
//********************************** */
async createInvoiceForSupportPlan(user: User, supPlan: SupportPlan, userSupPlan: UserSupportPlan, dueDate: Date, qryRnr: QueryRunner) {
async createInvoiceForSupportPlan(
user: User,
supPlan: SupportPlan,
userSupPlan: UserSupportPlan,
dueDate: Date,
qryRnr: QueryRunner,
price?: number,
) {
const originalPrice = supPlan.price;
const finalPrice = supPlan.price;
const finalPrice = price || supPlan.price;
const invoiceItem = {
name: supPlan.name,
@@ -593,6 +600,9 @@ export class InvoicesService {
subscriptionPlan: {
plan: true,
},
supportPlan: {
supportPlan: true,
},
},
},
});
@@ -10,6 +10,7 @@ import {
IsString,
MaxLength,
Min,
ValidateIf,
ValidateNested,
} from "class-validator";
@@ -30,6 +31,7 @@ export class CreateSupportPlanDto {
@ApiProperty({ description: "The description of the support plan" })
description: string;
@ValidateIf((object) => object.price > 0)
@IsNotEmpty({ message: SupportPlanMessage.PRICE_REQUIRED })
@IsNumber({}, { message: SupportPlanMessage.PRICE_NUMBER })
@Min(10000, { message: SupportPlanMessage.PRICE_MIN })
@@ -15,6 +15,9 @@ export class SupportPlan extends BaseEntity {
@Column({ type: "decimal", precision: 16, scale: 2, nullable: false, transformer: new DecimalTransformer() })
price: Decimal;
@Column({ type: "boolean", default: false })
isFree: boolean;
@Column({ type: "int", nullable: false })
duration: number; //days
@@ -1,5 +1,6 @@
import { BadRequestException, Injectable } from "@nestjs/common";
import dayjs from "dayjs";
import { Decimal } from "decimal.js";
import { DataSource } from "typeorm";
import { SupportPlanMessage } from "../../../common/enums/message.enum";
@@ -26,7 +27,7 @@ export class SupportPlansService {
async createSupportPlan(createSupportPlanDto: CreateSupportPlanDto) {
const supportPlan = this.supportPlanRepo.create(createSupportPlanDto);
await this.supportPlanRepo.save(supportPlan);
await this.supportPlanRepo.save({ ...supportPlan, isFree: createSupportPlanDto.price === 0 });
return {
message: SupportPlanMessage.SUPPORT_PLAN_CREATED,
supportPlan,
@@ -123,13 +124,13 @@ export class SupportPlansService {
where: { user: { id: userId }, supportPlan: { id } },
});
if (existingUserSupportPlan) throw new BadRequestException(SupportPlanMessage.SUPPORT_PLAN_ALREADY_SUBSCRIBED_OR_INVOICE_EXIST);
if (existingUserSupportPlan) throw new BadRequestException(SupportPlanMessage.SUPPORT_PLAN_ALREADY_SUBSCRIBED);
const existingUserSupport = await queryRunner.manager.findOne(this.userSupportPlanRepo.target, {
where: { user: { id: userId } },
});
if (existingUserSupport) throw new BadRequestException(SupportPlanMessage.USER_ALREADY_HAS_SUPPORT_PLAN);
if (existingUserSupport) throw new BadRequestException(SupportPlanMessage.SUPPORT_PLAN_ALREADY_SUBSCRIBED_OR_INVOICE_EXIST);
const startDate = dayjs().toDate();
const endDate = dayjs().add(supportPlan.duration, "day").toDate();
@@ -163,4 +164,94 @@ export class SupportPlansService {
await queryRunner.release();
}
}
//***************************************** */
async getUserSupportPlanOfUser(userId: string) {
const userSupportPlan = await this.userSupportPlanRepo.findOne({
where: { user: { id: userId }, status: UserSupportPlanStatus.ACTIVE },
relations: { supportPlan: { features: true } },
});
return { userSupportPlan };
}
//***************************************** */
async upgradeSupportPlan(newPlanId: string, userId: string) {
const queryRunner = this.dataSource.createQueryRunner();
try {
await queryRunner.connect();
await queryRunner.startTransaction();
const user = await this.usersService.findOneByIdWithQueryRunner(userId, queryRunner);
const currentUserSupportPlan = await queryRunner.manager.findOne(this.userSupportPlanRepo.target, {
where: { user: { id: userId } },
relations: { supportPlan: true },
});
if (!currentUserSupportPlan) throw new BadRequestException(SupportPlanMessage.USER_HAS_NO_SUPPORT_PLAN);
const newSupportPlan = await queryRunner.manager.findOne(this.supportPlanRepo.target, {
where: { id: newPlanId },
});
if (!newSupportPlan) throw new BadRequestException(SupportPlanMessage.SUPPORT_PLAN_NOT_FOUND);
const newSupportPlanPrice = new Decimal(newSupportPlan.price);
const currentUserSupportPlanPrice = new Decimal(currentUserSupportPlan.supportPlan.price);
// check if new plan is actually an upgrade
if (newSupportPlanPrice.lessThanOrEqualTo(currentUserSupportPlanPrice)) {
throw new BadRequestException(SupportPlanMessage.DOWN_GRADE_NOT_ALLOWED);
}
// calculate remaining days and price
const remainingDays = dayjs(currentUserSupportPlan.endDate).diff(dayjs(), "day");
const dailyPrice = currentUserSupportPlanPrice.div(currentUserSupportPlan.supportPlan.duration);
const remainingPrice = dailyPrice.mul(remainingDays);
// calculate upgrade price
const upgradePrice = newSupportPlanPrice.sub(remainingPrice).round();
// create new user support plan
const startDate = dayjs().toDate();
const endDate = dayjs().add(newSupportPlan.duration, "day").toDate();
const newUserSupportPlan = queryRunner.manager.create(this.userSupportPlanRepo.target, {
user,
supportPlan: newSupportPlan,
startDate,
endDate,
status: UserSupportPlanStatus.INACTIVE,
});
await queryRunner.manager.save(this.userSupportPlanRepo.target, newUserSupportPlan);
// create invoice for upgrade
const invoiceDueDate = dayjs(startDate).add(INVOICE.DUEDATE, "day").toDate();
const invoice = await this.invoicesService.createInvoiceForSupportPlan(
user,
newSupportPlan,
newUserSupportPlan,
invoiceDueDate,
queryRunner,
upgradePrice.toNumber(),
);
// cancel old support plan
currentUserSupportPlan.status = UserSupportPlanStatus.CANCELLED;
await queryRunner.manager.save(this.userSupportPlanRepo.target, currentUserSupportPlan);
await queryRunner.commitTransaction();
return {
message: SupportPlanMessage.SUPPORT_PLAN_UPGRADE_INITIATED_SUCCESSFULLY,
invoice,
upgradePrice: upgradePrice.toNumber(),
};
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
}
@@ -30,7 +30,7 @@ export class SupportPlanRepository extends Repository<SupportPlan> {
}
async getSupportPlansListUser() {
return this.find({ where: { isActive: true, deletedAt: IsNull() }, relations: { features: true } });
return this.find({ where: { isActive: true, deletedAt: IsNull() }, order: { price: "ASC" }, relations: { features: true } });
}
//***************************************** */
async getSupportPlanById(id: string) {
@@ -3,10 +3,17 @@ import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { UserSupportPlan } from "../entities/user-support-plan.entity";
import { UserSupportPlanStatus } from "../enums/user-support-plan-status.enum";
@Injectable()
export class UserSupportPlanRepository extends Repository<UserSupportPlan> {
constructor(@InjectRepository(UserSupportPlan) userSupportPlanRepo: Repository<UserSupportPlan>) {
super(userSupportPlanRepo.target, userSupportPlanRepo.manager, userSupportPlanRepo.queryRunner);
}
async getUserSupportPlanOfUser(userId: string) {
const userSupportPlan = await this.findOne({
where: { user: { id: userId }, status: UserSupportPlanStatus.ACTIVE },
});
return userSupportPlan;
}
}
@@ -7,8 +7,10 @@ import { UpdateSupportPlanDto } from "./DTO/update-support-plan-feature.dto";
import { SupportPlansService } from "./providers/support-plans.service";
import { AdminRoute } from "../../common/decorators/admin.decorator";
import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
import { PermissionsDec } from "../../common/decorators/permission.decorator";
import { UserDec } from "../../common/decorators/user.decorator";
import { ParamDto } from "../../common/DTO/param.dto";
import { PermissionEnum } from "../users/enums/permission.enum";
@Controller("support-plans")
@AuthGuards()
@@ -16,7 +18,7 @@ export class SupportPlansController {
constructor(private readonly supportPlansService: SupportPlansService) {}
@AdminRoute()
// @PermissionsDec(PermissionEnum.SUPPORT_PLAN)
@PermissionsDec(PermissionEnum.SUPPORT_PLAN)
@ApiOperation({ summary: "Create a new support plan (admin)" })
@Post()
createSupportPlan(@Body() createSupportPlanDto: CreateSupportPlanDto) {
@@ -24,7 +26,7 @@ export class SupportPlansController {
}
@AdminRoute()
// @PermissionsDec(PermissionEnum.SUPPORT_PLAN)
@PermissionsDec(PermissionEnum.SUPPORT_PLAN)
@ApiOperation({ summary: "Get all support plans (admin)" })
@Get("list")
getSupportPlansList(@Query() queryDto: GetSupportPlanListQueryDto) {
@@ -32,7 +34,7 @@ export class SupportPlansController {
}
@AdminRoute()
// @PermissionsDec(PermissionEnum.SUPPORT_PLAN)
@PermissionsDec(PermissionEnum.SUPPORT_PLAN)
@ApiOperation({ summary: "Get a support plan by id (admin)" })
@Get(":id")
getSupportPlanById(@Param() paramDto: ParamDto) {
@@ -40,7 +42,7 @@ export class SupportPlansController {
}
@AdminRoute()
// @PermissionsDec(PermissionEnum.SUPPORT_PLAN)
@PermissionsDec(PermissionEnum.SUPPORT_PLAN)
@ApiOperation({ summary: "Delete a support plan by id (admin)" })
@Delete(":id")
deleteSupportPlanById(@Param() paramDto: ParamDto) {
@@ -48,7 +50,7 @@ export class SupportPlansController {
}
@AdminRoute()
// @PermissionsDec(PermissionEnum.SUPPORT_PLAN)
@PermissionsDec(PermissionEnum.SUPPORT_PLAN)
@ApiOperation({ summary: "Update a support plan by id (admin)" })
@Patch(":id")
updateSupportPlanById(@Param() paramDto: ParamDto, @Body() updateSupportPlanDto: UpdateSupportPlanDto) {
@@ -66,4 +68,16 @@ export class SupportPlansController {
subscribeToSupportPlan(@Param() paramDto: ParamDto, @UserDec("id") userId: string) {
return this.supportPlansService.subscribeToSupportPlan(paramDto.id, userId);
}
@ApiOperation({ summary: "Get user support plan of user" })
@Get("user")
getUserSupportPlanOfUser(@UserDec("id") userId: string) {
return this.supportPlansService.getUserSupportPlanOfUser(userId);
}
@ApiOperation({ summary: "Upgrade user support plan" })
@Post(":id/upgrade")
upgradeSupportPlan(@Param() paramDto: ParamDto, @UserDec("id") userId: string) {
return this.supportPlansService.upgradeSupportPlan(paramDto.id, userId);
}
}