import { InjectQueue } from "@nestjs/bullmq"; import { BadRequestException, ForbiddenException, Injectable, Logger } from "@nestjs/common"; import { ModuleRef } from "@nestjs/core"; import { Queue } from "bullmq"; import dayjs from "dayjs"; import Decimal from "decimal.js"; import slugify from "slugify"; import { DataSource, In, Not, QueryRunner } from "typeorm"; import { ServiceMessage, SubscriptionMessage } from "../../../common/enums/message.enum"; import { DanakServicesService } from "../../danak-services/providers/danak-services.service"; import { RestaurantService } from "../../dmenu/providers/restaurant.service"; import { INVOICE } from "../../invoices/constants"; import { InvoicesService } from "../../invoices/providers/invoices.service"; import { User } from "../../users/entities/user.entity"; import { UsersService } from "../../users/providers/users.service"; import { PaginationUtils } from "../../utils/providers/pagination.utils"; import { SUBSCRIPTIONS } from "../constants"; import { AddSubscriptionsToServiceDto } from "../DTO/create-subscription.dto"; import { AdminUserSubscriptionsQueryDto } from "../DTO/admin-user-subscriptions-query.dto"; import { SearchUserSubsQueryDto } from "../DTO/search-user-subs-query.dto"; import { ServiceSubsQueryDto } from "../DTO/service-subs-query.dto"; import { ExtendUSerSubscribeServiceDto, SubscribeServiceDto } from "../DTO/subscribe-service.dto"; import { SubscriptionAccessDto } from "../DTO/subscription-access-dto"; import { UpdateSubscriptionPlanDto } from "../DTO/update-subscription.dto"; import { SubscriptionPlan } from "../entities/subscription.entity"; import { UserSubscription } from "../entities/user-subscription.entity"; import { SubscriptionStatus } from "../enums/subscription-status.enum"; import { IBusinessProvisioningJob } from "../interfaces/IProvisioning-job-data"; import { SubscriptionsPlanRepository } from "../repositories/subscriptions.repository"; import { UserSubscriptionsRepository } from "../repositories/user-subscriptions.repository"; import { InvoicePurpose } from "../../invoices/interfaces/external-invoice.interface"; @Injectable() export class SubscriptionsService { private readonly logger: Logger = new Logger(SubscriptionsService.name); constructor( @InjectQueue(SUBSCRIPTIONS.PROVISIONING_QUEUE_NAME) private readonly provisioningQueue: Queue, private readonly subscriptionsPlanRepository: SubscriptionsPlanRepository, private readonly userSubscriptionsRepository: UserSubscriptionsRepository, private readonly moduleRef: ModuleRef, private readonly usersService: UsersService, private readonly danakServices: DanakServicesService, private readonly dataSource: DataSource, ) { } private get invoicesService(): InvoicesService { return this.moduleRef.get(InvoicesService, { strict: false }); } private get restaurantService(): RestaurantService { return this.moduleRef.get(RestaurantService, { strict: false }); } //************************************ */ async createSubscriptionsPlan(createDto: AddSubscriptionsToServiceDto) { 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) => { const adjustedPrice = sub.price < 100_000 ? new Decimal(0) : new Decimal(sub.price); const isFree = sub.price < 100_000; if (isFree) { console.log(`🎁 Plan "${sub.name}" marked as FREE (price ${sub.price} < 100,000 IRR)`); } else { console.log(`💰 Plan "${sub.name}" price: ${adjustedPrice.toNumber()} IRR`); } return { ...sub, service: { id: createDto.serviceId }, price: adjustedPrice, originalPrice: adjustedPrice, isFree, }; }); const subscriptionNames = createDto.subs.map((sub) => sub.name); const existingSubscriptions = await this.subscriptionsPlanRepository.find({ where: { name: In(subscriptionNames), service: { id: createDto.serviceId }, }, }); if (existingSubscriptions.length > 0) throw new BadRequestException({ message: [SubscriptionMessage.NAME_EXIST, existingSubscriptions] }); const createdSubscriptions = this.subscriptionsPlanRepository.create(subscriptions); await this.subscriptionsPlanRepository.save(createdSubscriptions); return { message: SubscriptionMessage.CREATED, subscriptions: createdSubscriptions, }; } //************************************ */ async getUserSubscriptions(userId: string, queryDto: SearchUserSubsQueryDto) { const queryBuilder = this.userSubscriptionsRepository .createQueryBuilder("userSubscription") .leftJoinAndSelect("userSubscription.plan", "plan") .leftJoinAndSelect("plan.service", "service") .where("userSubscription.user.id = :userId", { userId }); if (queryDto.q) { queryBuilder.andWhere("(service.name ILIKE :query OR service.title ILIKE :query OR service.description ILIKE :query)", { query: `%${queryDto.q}%`, }); } const subscriptions = await queryBuilder.getMany(); return { subscriptions, }; } //************************************ */ async getUserSubscriptionById(userSubId: string, userId: string) { const userSubscription = await this.userSubscriptionsRepository.findOne({ where: { id: userSubId, user: { id: userId } }, relations: { plan: { service: { images: true, subscriptionPlans: true, }, }, }, }); if (!userSubscription) throw new BadRequestException(SubscriptionMessage.USER_SUBS_NOT_FOUND); return { userSubscription, }; } //************************************ */ async updateSubscriptionPlan(id: string, updateDto: UpdateSubscriptionPlanDto) { const subscription = await this.subscriptionsPlanRepository.findOne({ where: { id }, relations: { service: true } }); if (!subscription) throw new BadRequestException(SubscriptionMessage.NOT_FOUND); if (updateDto.serviceId) { const danakService = await this.danakServices.findServiceById(updateDto.serviceId); if (!danakService) throw new BadRequestException(ServiceMessage.SERVICE_NOT_FOUND_BY_ID); subscription.service = danakService; } if (updateDto.name) { const existSubscription = await this.subscriptionsPlanRepository.findOneBy({ name: updateDto.name, id: Not(id), service: { id: subscription.service.id }, }); if (existSubscription) throw new BadRequestException(SubscriptionMessage.NAME_EXIST); subscription.name = updateDto.name; } if (updateDto.duration) subscription.duration = updateDto.duration; if (updateDto.price) { const adjustedPrice = updateDto.price < 100_000 ? new Decimal(0) : new Decimal(updateDto.price); const isFree = updateDto.price < 100_000; if (isFree) { console.log(`🎁 Plan "${subscription.name}" updated to FREE (price ${updateDto.price} < 100,000 IRR)`); } else { console.log(`💰 Plan "${subscription.name}" price updated to: ${adjustedPrice.toNumber()} IRR`); } subscription.price = adjustedPrice; subscription.originalPrice = adjustedPrice; } await this.subscriptionsPlanRepository.save({ ...subscription, isFree: updateDto.price ? updateDto.price < 100_000 : false, }); return { message: SubscriptionMessage.UPDATED, subscription, }; } //************************************ */ async getSubscriptionPlanById(subscriptionId: string) { const subscription = await this.subscriptionsPlanRepository.findOneBy({ id: subscriptionId }); if (!subscription) throw new BadRequestException(SubscriptionMessage.NOT_FOUND); return { subscription, }; } //************************************ */ async All(serviceId: string, queryDto: ServiceSubsQueryDto) { const service = await this.danakServices.findServiceById(serviceId); const { limit, skip } = PaginationUtils(queryDto); const queryBuilder = this.subscriptionsPlanRepository .createQueryBuilder("subscription") .leftJoin("subscription.service", "service") .addSelect(["service.id"]) // .leftJoin("subscription.discounts", "discount") // .addSelect(["discount.calculationType", "discount.amount", "discount.startDate", "discount.endDate", "discount.isActive"]) .where("service.id = :serviceId", { serviceId }); if (queryDto.q) { queryBuilder.andWhere("subscription.name ILIKE :query", { query: `%${queryDto.q}%` }); } if (queryDto.isActive !== undefined) { queryBuilder.andWhere("subscription.isActive = :isActive", { isActive: queryDto.isActive === 1 }); } const [subscriptions, count] = await queryBuilder.skip(skip).take(limit).getManyAndCount(); return { service, subscriptions, count, pagination: true, }; } //************************************ */ async toggleSubStatus(subId: string) { const subscription = await this.subscriptionsPlanRepository.findOneBy({ id: subId }); if (!subscription) throw new BadRequestException(SubscriptionMessage.NOT_FOUND); subscription.isActive = !subscription.isActive; await this.subscriptionsPlanRepository.save(subscription); return { message: SubscriptionMessage.STATUS_UPDATED, isActive: subscription.isActive, }; } //************************************ */ async subscribeToPlan(serviceId: string, subscribeDto: SubscribeServiceDto, userId: string) { const queryRunner = this.dataSource.createQueryRunner(); let isFree = false; try { await queryRunner.connect(); await queryRunner.startTransaction(); const user = await this.usersService.findOneByIdWithQueryRunner(userId, queryRunner); const plan = await queryRunner.manager.findOne(SubscriptionPlan, { where: { id: subscribeDto.planId, service: { id: serviceId } }, relations: { service: true, directDiscount: true, }, }); if (!plan) throw new BadRequestException(SubscriptionMessage.NOT_FOUND); if (plan.isFree) { isFree = true; // const oneMonthAgo = dayjs().subtract(1, "month").toDate(); const existingFreePlanSubscription = await queryRunner.manager.findOne(UserSubscription, { where: { user: { id: userId }, plan: { isFree: true, service: { id: serviceId, }, }, // startDate: MoreThan(oneMonthAgo), }, relations: { plan: true, }, }); if (existingFreePlanSubscription) { throw new BadRequestException(SubscriptionMessage.FREE_PLAN_LIMIT_EXCEEDED); } } const startDate = dayjs().toDate(); const endDate = dayjs().add(plan.duration, "day").toDate(); let slug = subscribeDto.slug; if (!slug) { slug = slugify(subscribeDto.businessName, { lower: true, strict: true }); } const existingSlug = await queryRunner.manager.findOne(UserSubscription, { where: { slug, deletedAt: undefined } }); if (existingSlug) throw new BadRequestException(SubscriptionMessage.SLUG_EXISTS); let staff: User[] = []; if (subscribeDto.staff && subscribeDto.staff.length > 0) { staff = await this.usersService.findUsersByIds(subscribeDto.staff); } const userSubscription = queryRunner.manager.create(UserSubscription, { ...subscribeDto, user, plan, startDate, endDate, slug, staff, }); await queryRunner.manager.save(UserSubscription, userSubscription); const invoiceDueDate = dayjs(userSubscription.startDate).add(INVOICE.DUEDATE, "day").toDate(); const invoice = await this.invoicesService.createInvoiceForSubscription(user, plan, userSubscription, invoiceDueDate, queryRunner, InvoicePurpose.NEW); userSubscription.status = SubscriptionStatus.INACTIVE; await queryRunner.manager.save(UserSubscription, userSubscription); await this.addProvisioningJob(userSubscription, plan, user); if (isFree) { await this.invoicesService.payInvoice(invoice.id, user.id, undefined, queryRunner); } await queryRunner.commitTransaction(); return { message: SubscriptionMessage.SUBSCRIBED, userSubscription, invoice, }; } catch (error) { await queryRunner.rollbackTransaction(); throw error; } finally { await queryRunner.release(); } } //************************************ */ async renewUserSubscription(userSubscriptionId: string, dto: ExtendUSerSubscribeServiceDto, userId: string) { const queryRunner = this.dataSource.createQueryRunner(); try { await queryRunner.connect(); await queryRunner.startTransaction(); const userSubscription = await queryRunner.manager.findOne(UserSubscription, { where: { id: userSubscriptionId }, relations: { user: true, plan: true, }, }); if (!userSubscription) throw new BadRequestException(SubscriptionMessage.NOT_FOUND); const user = await this.usersService.findOneByIdWithQueryRunner(userId, queryRunner); const plan = await queryRunner.manager.findOne(SubscriptionPlan, { where: { id: dto.planId, service: { id: dto.serviceId } }, relations: { service: true, directDiscount: true }, }); if (!plan) throw new BadRequestException(SubscriptionMessage.PLAN_NOT_FOUND); if (plan.isFree) { throw new BadRequestException(SubscriptionMessage.FREE_PLAN_NOT_ALLOWED); } const invoiceDueDate = dayjs().add(INVOICE.DUEDATE, "day").toDate(); const invoice = await this.invoicesService.createInvoiceForSubscription(user, plan, userSubscription, invoiceDueDate, queryRunner, InvoicePurpose.RENEW); // userSubscription.plan = plan; // await queryRunner.manager.save(userSubscription); await this.addProvisioningJob(userSubscription, plan, user); await queryRunner.commitTransaction(); return { message: SubscriptionMessage.INVOICE_CREATED_SUCCESSFULLY, userSubscription, invoice, }; } catch (error) { await queryRunner.rollbackTransaction(); throw error; } finally { await queryRunner.release(); } } //************************************ */ async upgradeSubscription(userSubscriptionId: string, planId: string, userId: string) { const queryRunner = this.dataSource.createQueryRunner(); try { await queryRunner.connect(); await queryRunner.startTransaction(); const userSubscription = await queryRunner.manager.findOne(UserSubscription, { where: { id: userSubscriptionId, user: { id: userId } }, relations: { plan: { service: true }, user: true } }); if (!userSubscription) throw new BadRequestException(SubscriptionMessage.USER_SUBS_NOT_FOUND); // Check if restaurant exists for dmenu service if (userSubscription.plan.service.name.includes('دی منو')) { try { const rest = await this.restaurantService.getRestaurantSubscription(userSubscriptionId); console.log('rest', rest) } catch (error) { throw new BadRequestException('Restaurant* not found for this subscription'); } } const newPlan = await this.subscriptionsPlanRepository.findById(planId); if (!newPlan) throw new BadRequestException(SubscriptionMessage.PLAN_NOT_FOUND); if (userSubscription.plan.service.id !== newPlan.service.id) { throw new BadRequestException(SubscriptionMessage.SERVICE_MISMATCH); } // Validate that new plan is more expensive than current plan if (new Decimal(newPlan.price).lessThan(userSubscription.plan.price)) { throw new BadRequestException(SubscriptionMessage.UPGRADE_TO_LOWER_OR_EQUAL_PLAN_NOT_ALLOWED); } // Calculate remaining days in current subscription const today = dayjs(); const endDate = dayjs(userSubscription.endDate); const currentPlanRemainingDays = Math.max(0, endDate.diff(today, 'day')); console.log('🔢 CALCULATION LOGS:'); console.log(`📅 Remaining days: ${currentPlanRemainingDays} (endDate: ${endDate.format('YYYY-MM-DD')}, today: ${today.format('YYYY-MM-DD')})`); if (currentPlanRemainingDays <= 0) { throw new BadRequestException(SubscriptionMessage.SUBSCRIPTION_EXPIRED_CANNOT_UPGRADE); } const remainingDays = newPlan.duration - currentPlanRemainingDays // Calculate daily rates const currentPlanDuration = userSubscription.plan.duration; const newPlanDuration = newPlan.duration; const currentDailyRate = new Decimal(userSubscription.plan.price).div(currentPlanDuration); const newDailyRate = new Decimal(newPlan.price).div(newPlanDuration); console.log(`💰 Current plan: ${userSubscription.plan.price} IRR for ${currentPlanDuration} days = ${currentDailyRate.toNumber()} IRR/day`); console.log(`💰 New plan: ${newPlan.price} IRR for ${newPlanDuration} days = ${newDailyRate.toNumber()} IRR/day`); // Calculate price difference per day const dailyPriceDifference = newDailyRate.sub(currentDailyRate); console.log(`📈 Daily price difference: ${dailyPriceDifference.toNumber()} IRR/day`); // Calculate prorated upgrade cost for remaining days const proratedUpgradeCost = dailyPriceDifference.mul(currentPlanRemainingDays).add(newDailyRate.mul(remainingDays)) console.log(`🧮 Prorated upgrade cost: ${dailyPriceDifference.toNumber()} IRR/day × ${currentPlanRemainingDays} days = ${proratedUpgradeCost.toNumber()} IRR`); // Ensure minimum charge amount const minimumCharge = new Decimal(1000); // 1000 IRR minimum const finalUpgradeCost = + Decimal.max(proratedUpgradeCost, minimumCharge).toNumber().toFixed(0) console.log(`✅ Final upgrade cost: max(${proratedUpgradeCost.toNumber()} IRR, ${minimumCharge.toNumber()} IRR minimum) = ${finalUpgradeCost} IRR`); const user = await this.usersService.findOneByIdWithQueryRunner(userSubscription.user.id, queryRunner); const invoiceDueDate = dayjs().add(INVOICE.DUEDATE, "day").toDate(); const invoice = await this.invoicesService.createInvoiceForSubscriptionUpgrade( user, newPlan, userSubscription, invoiceDueDate, queryRunner, finalUpgradeCost ); // Update the subscription to the new plan // userSubscription.plan = newPlan; // await queryRunner.manager.save(UserSubscription, userSubscription); // Add provisioning job for the new plan await this.addProvisioningJob(userSubscription, newPlan, user); await queryRunner.commitTransaction(); return { message: SubscriptionMessage.INVOICE_CREATED_SUCCESSFULLY, userSubscription, invoice, upgradeDetails: { remainingDays: currentPlanRemainingDays, currentPlanPrice: userSubscription.plan.price, newPlanPrice: newPlan.price, proratedUpgradeCost: finalUpgradeCost, }, }; } catch (error) { await queryRunner.rollbackTransaction(); throw error; } finally { await queryRunner.release(); } } //************************************ */ async getServiceSubscriptions(serviceId: string, queryDto: ServiceSubsQueryDto) { const service = await this.danakServices.findServiceById(serviceId); const { limit, skip } = PaginationUtils(queryDto); const queryBuilder = this.subscriptionsPlanRepository .createQueryBuilder("subscription") .leftJoin("subscription.service", "service") .addSelect(["service.id"]) // .leftJoin("subscription.discounts", "discount") // .addSelect(["discount.calculationType", "discount.amount", "discount.startDate", "discount.endDate", "discount.isActive"]) .where("service.id = :serviceId", { serviceId }); if (queryDto.q) { queryBuilder.andWhere("subscription.name ILIKE :query", { query: `%${queryDto.q}%` }); } if (queryDto.isActive !== undefined) { queryBuilder.andWhere("subscription.isActive = :isActive", { isActive: queryDto.isActive === 1 }); } const [subscriptions, count] = await queryBuilder.skip(skip).take(limit).getManyAndCount(); return { service, subscriptions, count, pagination: true, }; } //************************************ */ async getAllServiceSubscriptions(serviceIds: string[], queryRunner: QueryRunner) { const subscriptions = await queryRunner.manager.find(this.subscriptionsPlanRepository.target, { where: { service: { id: In(serviceIds) }, isActive: true }, relations: { directDiscount: true, service: true }, }); return subscriptions; } //************************************ */ async countUserSubscriptions(userId: string) { const subscriptionCount = await this.userSubscriptionsRepository.count({ where: { user: { id: userId }, status: SubscriptionStatus.ACTIVE }, }); return subscriptionCount; } //************************************ */ async getUserSubscriptionsForService(userId: string, serviceId: string) { const userSubscriptions = await this.userSubscriptionsRepository.find({ where: [ { user: { id: userId }, plan: { service: { id: serviceId } }, status: SubscriptionStatus.ACTIVE }, { staff: { id: userId }, plan: { service: { id: serviceId } }, status: SubscriptionStatus.ACTIVE }, ], relations: { plan: { service: true }, staff: true, }, order: { endDate: "DESC" }, select: { id: true, businessName: true, businessPhone: true, description: true, slug: true, status: true, endDate: true, startDate: true, plan: { id: true, name: true, duration: true, service: { id: true, name: true, slug: true, title: true } }, staff: { id: true, firstName: true, lastName: true, email: true, phone: true }, }, }); if (!userSubscriptions.length) throw new ForbiddenException(SubscriptionMessage.USER_DOES_NOT_HAVE_ACCESS); return { workspaces: userSubscriptions }; } //************************************ */ async checkUserAccessToService(serviceId: string, accessDto: SubscriptionAccessDto) { const userSubscriptions = await this.userSubscriptionsRepository.findOne({ where: [ { id: accessDto.danakSubscriptionId, user: { id: accessDto.userId }, plan: { service: { id: serviceId } }, status: SubscriptionStatus.ACTIVE, }, { id: accessDto.danakSubscriptionId, staff: { id: accessDto.userId }, plan: { service: { id: serviceId } }, status: SubscriptionStatus.ACTIVE, }, ], relations: { plan: { service: true }, staff: true, }, }); return { hasAccess: !!userSubscriptions && userSubscriptions.status === SubscriptionStatus.ACTIVE }; } //************************************ */ async addProvisioningJob(userSubscription: UserSubscription, plan: SubscriptionPlan, user: User) { this.logger.debug(`Adding provisioning job for user ${user.id} and subscription ${userSubscription.id}`); await this.provisioningQueue.add( SUBSCRIPTIONS.PROVISIONING_JOB_NAME, { subscriptionId: userSubscription.id, serviceId: plan.service.id, serviceName: plan.service.name, businessName: userSubscription.businessName, userId: user.id, slug: userSubscription.slug, phone: user.phone, email: user.email, fullName: `${user.firstName} ${user.lastName}`, } as IBusinessProvisioningJob, { priority: SUBSCRIPTIONS.PROVISIONING_JOB_PRIORITY, delay: SUBSCRIPTIONS.PROVISIONING_JOB_DELAY, attempts: SUBSCRIPTIONS.PROVISIONING_JOB_ATTEMPTS, backoff: { type: "exponential", delay: SUBSCRIPTIONS.PROVISIONING_JOB_BACKOFF }, }, ); } //************************************ */ async getAllUserSubscriptions(queryDto: AdminUserSubscriptionsQueryDto) { const { limit, skip } = PaginationUtils(queryDto); const queryBuilder = this.userSubscriptionsRepository .createQueryBuilder("userSubscription") .leftJoinAndSelect("userSubscription.user", "user") .leftJoinAndSelect("userSubscription.plan", "plan") .leftJoin("plan.service", "service") .addSelect(["service.name", "service.title"]); if (queryDto.q) { queryBuilder.andWhere( "(userSubscription.businessName ILIKE :query OR userSubscription.description ILIKE :query OR user.email ILIKE :query OR user.phone ILIKE :query OR user.firstName ILIKE :query OR user.lastName ILIKE :query OR service.name ILIKE :query OR service.title ILIKE :query)", { query: `%${queryDto.q}%` } ); } if (queryDto.userId) { queryBuilder.andWhere("user.id = :userId", { userId: queryDto.userId }); } if (queryDto.serviceId) { queryBuilder.andWhere("service.id = :serviceId", { serviceId: queryDto.serviceId }); } if (queryDto.status) { queryBuilder.andWhere("userSubscription.status = :status", { status: queryDto.status }); } if (queryDto.startDateFrom) { queryBuilder.andWhere("userSubscription.startDate >= :startDateFrom", { startDateFrom: queryDto.startDateFrom }); } if (queryDto.startDateTo) { queryBuilder.andWhere("userSubscription.startDate <= :startDateTo", { startDateTo: queryDto.startDateTo }); } if (queryDto.endDateFrom) { queryBuilder.andWhere("userSubscription.endDate >= :endDateFrom", { endDateFrom: queryDto.endDateFrom }); } if (queryDto.endDateTo) { queryBuilder.andWhere("userSubscription.endDate <= :endDateTo", { endDateTo: queryDto.endDateTo }); } queryBuilder.orderBy("userSubscription.createdAt", "DESC"); const [userSubscriptions, count] = await queryBuilder.skip(skip).take(limit).getManyAndCount(); return { userSubscriptions, count, pagination: true, }; } //************************************ */ async softDeleteUSerSubscription(userSubscriptionId: string) { const usersubscription = await this.userSubscriptionsRepository.findOne({ where: { id: userSubscriptionId } }) if (!usersubscription) { throw new BadRequestException(SubscriptionMessage.NOT_FOUND) } await this.userSubscriptionsRepository.softDelete(userSubscriptionId) return { message: SubscriptionMessage.SUBSCRIPTION_DELETED } } // ******************************** // async findOneOrFail(userSubscriptionId: string){ const usersubscription = await this.userSubscriptionsRepository.findOne({ where: { id: userSubscriptionId },relations:['user'] }) if (!usersubscription) { throw new BadRequestException(SubscriptionMessage.NOT_FOUND) } return usersubscription } }