chore: add business logic

This commit is contained in:
Mahyargdz
2025-05-18 10:19:16 +03:30
parent 1bc8b23452
commit b5c90e8c2e
12 changed files with 119 additions and 187 deletions
+5
View File
@@ -478,6 +478,11 @@ export const enum SubscriptionMessage {
BUSINESS_DESCRIPTION_STRING = "توضیحات کسب و کار باید یک رشته باشد",
IS_ACTIVE_SHOULD_BE_1_0 = "وضعیت اشتراک باید یکی از مقادیر ۰ و ۱ باشد",
NOT_PURCHASED_CANNOT_REVIEW = "شما این اشتراک را خریداری نکرده‌اید و نمی‌توانید نظر بدهید",
SLUG_REQUIRED = "اسلاگ نمی‌تواند خالی باشد",
SLUG_STRING = "اسلاگ باید یک رشته باشد",
SLUG_LENGTH = "اسلاگ باید بین ۴ تا ۲۵۰ کاراکتر باشد",
SLUG_EXISTS = "این اسلاگ قبلا ثبت شده است",
USER_DOES_NOT_HAVE_ACCESS = "شما دسترسی به این سرویس ندارید",
}
export const enum InvoiceMessage {
+1 -1
View File
@@ -37,7 +37,7 @@ export class TokensService {
const accessExpire = this.configService.getOrThrow<number>("ACCESS_TOKEN_EXPIRE");
const refreshExpire = this.configService.getOrThrow<number>("REFRESH_TOKEN_EXPIRE");
const accessToken = this.jwtService.sign(payload, { expiresIn: `${accessExpire}m` });
const accessToken = this.jwtService.sign(payload, { expiresIn: `${accessExpire}h` });
const refreshToken = this.jwtService.sign({ id: payload.id }, { expiresIn: `${refreshExpire}d` });
@@ -46,24 +46,12 @@ export class DashboardService {
const invoicesCount = await this.getInvoicesCount();
const unreadTickets = await this.unreadTickets();
const adsCount = await this.getAdsCount();
const planRevenueAverage = await this.getPlanRevenueAverage();
const plansSalesCount = await this.getPlansSalesCount();
const subscriptionGrowthRate = await this.getSubscriptionGrowthRate();
const plansSalesCountByMonthsInYear = await this.getPlansSalesCountByMonthsInYear();
const plansSalesCountByDaysInMonth = await this.getPlansSalesCountByDaysInMonth();
const plansSalesCountByDaysInWeek = await this.getPlansSalesCountByDaysInWeek();
return {
danakServicesCount,
customersCount,
invoicesCount,
unreadTickets,
adsCount,
planRevenueAverage,
plansSalesCount,
subscriptionGrowthRate,
plansSalesCountByMonthsInYear,
plansSalesCountByDaysInMonth,
plansSalesCountByDaysInWeek,
};
}
@@ -103,44 +91,4 @@ export class DashboardService {
}
//************************************ */
private async getPlanRevenueAverage() {
const planRevenueCount = await this.subscriptionsService.getPlanRevenueAverage();
return planRevenueCount;
}
//************************************ */
private async getPlansSalesCount() {
const plansSalesCount = await this.subscriptionsService.getPlansSalesCount();
return plansSalesCount;
}
//************************************ */
private async getSubscriptionGrowthRate() {
const subscriptionGrowthRate = await this.subscriptionsService.getSubscriptionGrowthRate();
return subscriptionGrowthRate;
}
//************************************ */
private async getPlansSalesCountByMonthsInYear() {
const plansSalesCountByMonthsInYear = await this.subscriptionsService.getPlansSalesCountByMonthsInYear();
return plansSalesCountByMonthsInYear;
}
//************************************ */
private async getPlansSalesCountByDaysInMonth() {
const plansSalesCountByDaysInMonth = await this.subscriptionsService.getPlansSalesCountByDaysInMonth();
return plansSalesCountByDaysInMonth;
}
//************************************ */
private async getPlansSalesCountByDaysInWeek() {
const plansSalesCountByDaysInWeek = await this.subscriptionsService.getPlansSalesCountByDaysInWeek();
return plansSalesCountByDaysInWeek;
}
}
@@ -578,6 +578,7 @@ export class InvoicesService {
await this.walletsService.createSubscriptionTransaction(invoice.totalPrice, userWallet.id, queryRunner);
await this.addNotifyForWalletDeduction(invoice, user, userWallet, WalletMessage.SUBSCRIPTION_WALLET_TRANSFER);
await this.addNotifyAdminForSubscriptionPaymentToQueue(invoice);
//
} else if (invoice.items[0]?.supportPlan) {
const oldUserSupportPlan = await queryRunner.manager.findOne(UserSupportPlan, {
@@ -515,6 +515,7 @@ export class NotificationsService {
await this.emailService.sendAdminNotificationEmail({
userEmail: data.userEmail,
title: NotificationMessage.NEW_SUBSCRIPTION,
fullName: data.fullName,
message,
});
}
@@ -1,5 +1,5 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsNotEmpty, IsPhoneNumber, IsString, IsUUID, Length } from "class-validator";
import { IsArray, IsNotEmpty, IsOptional, IsPhoneNumber, IsString, IsUUID, Length } from "class-validator";
import { SubscriptionMessage } from "../../../common/enums/message.enum";
@@ -25,4 +25,17 @@ export class SubscribeServiceDto {
@Length(5, 500, { message: SubscriptionMessage.BUSINESS_DESCRIPTION_LENGTH })
@ApiProperty({ description: "A short description of the business or service", example: "We offer the best subscription plans." })
description: string;
@IsOptional()
@IsNotEmpty({ message: SubscriptionMessage.SLUG_REQUIRED })
@IsString({ message: SubscriptionMessage.SLUG_STRING })
@Length(4, 250, { message: SubscriptionMessage.SLUG_LENGTH })
@ApiProperty({ description: "Unique slug for public URLs", example: "my-restaurant", required: false })
slug?: string;
@IsOptional()
@IsArray()
@IsUUID(4, { each: true })
@ApiProperty({ description: "Array of user IDs to add as staff", type: [String], required: false })
staff?: string[];
}
@@ -0,0 +1,11 @@
export const SUBSCRIPTIONS = Object.freeze({
PROVISIONING_QUEUE_PREFIX: "provisioning",
PROVISIONING_QUEUE_NAME: "provisioning",
PROVISIONING_JOB_NAME: "business.created",
PROVISIONING_JOB_DELAY: 1000,
PROVISIONING_JOB_PRIORITY: 1, // high priority
PROVISIONING_JOB_ATTEMPTS: 3, // retry 3 times
PROVISIONING_JOB_BACKOFF: 5 * 1000, // retry after 5 second
PROVISIONING_JOB_TIMEOUT: 10000, //
});
@@ -1,4 +1,4 @@
import { Column, Entity, ManyToOne } from "typeorm";
import { Column, Entity, JoinTable, ManyToMany, ManyToOne } from "typeorm";
import { SubscriptionPlan } from "./subscription.entity";
import { BaseEntity } from "../../../common/entities/base.entity";
@@ -28,6 +28,13 @@ export class UserSubscription extends BaseEntity {
@Column({ type: "text", nullable: false })
description: string;
@Column({ type: "varchar", length: 250, unique: true, nullable: true })
slug: string;
@Column({ type: "enum", enum: SubscriptionStatus, default: SubscriptionStatus.INACTIVE })
status: SubscriptionStatus;
@ManyToMany(() => User)
@JoinTable()
staff: User[];
}
@@ -1,14 +1,19 @@
import { BadRequestException, Injectable } from "@nestjs/common";
import { InjectQueue } from "@nestjs/bullmq";
import { BadRequestException, ForbiddenException, Injectable } from "@nestjs/common";
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 { 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 { SearchUserSubsQueryDto } from "../DTO/search-user-subs-query.dto";
import { ServiceSubsQueryDto } from "../DTO/service-subs-query.dto";
@@ -23,6 +28,7 @@ import { UserSubscriptionsRepository } from "../repositories/user-subscriptions.
@Injectable()
export class SubscriptionsService {
constructor(
@InjectQueue(SUBSCRIPTIONS.PROVISIONING_QUEUE_NAME) private readonly provisioningQueue: Queue,
private readonly subscriptionsPlanRepository: SubscriptionsPlanRepository,
private readonly userSubscriptionsRepository: UserSubscriptionsRepository,
private readonly invoicesService: InvoicesService,
@@ -196,7 +202,6 @@ export class SubscriptionsService {
async subscribeToPlan(serviceId: string, subscribeDto: SubscribeServiceDto, userId: string) {
const queryRunner = this.dataSource.createQueryRunner();
try {
await queryRunner.connect();
await queryRunner.startTransaction();
@@ -210,18 +215,35 @@ export class SubscriptionsService {
directDiscount: true,
},
});
if (!plan) throw new BadRequestException(SubscriptionMessage.NOT_FOUND);
const startDate = dayjs().toDate();
const endDate = dayjs().add(plan.duration, "day").toDate();
//
const userSubscription = queryRunner.manager.create(UserSubscription, { ...subscribeDto, user, plan, startDate, endDate });
let slug = subscribeDto.slug;
if (!slug) {
slug = slugify(subscribeDto.businessName, { lower: true, strict: true });
}
const existingSlug = await queryRunner.manager.findOne(UserSubscription, { where: { slug } });
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);
@@ -229,6 +251,8 @@ export class SubscriptionsService {
await queryRunner.manager.save(UserSubscription, userSubscription);
await this.addProvisioningJob(userSubscription, plan, user);
await queryRunner.commitTransaction();
return {
message: SubscriptionMessage.SUBSCRIBED,
@@ -293,134 +317,43 @@ export class SubscriptionsService {
}
//************************************ */
async getPlanRevenueAverage() {
const currentYear = new Date().getFullYear();
const startOfYear = new Date(currentYear, 0, 1);
const endOfYear = new Date(currentYear, 11, 31);
const result = await this.userSubscriptionsRepository
.createQueryBuilder("userSubscription")
.leftJoin("userSubscription.plan", "plan")
.select("AVG(plan.price)", "averageRevenue")
.where("userSubscription.startDate >= :startDate", { startDate: startOfYear })
.andWhere("userSubscription.startDate <= :endDate", { endDate: endOfYear })
.andWhere("userSubscription.status = :status", { status: SubscriptionStatus.ACTIVE })
.getRawOne();
return Number(result.averageRevenue) || 0;
}
async getPlansSalesCount() {
const currentYear = new Date().getFullYear();
const startOfYear = new Date(currentYear, 0, 1);
const endOfYear = new Date(currentYear, 11, 31);
return await this.userSubscriptionsRepository
.createQueryBuilder("userSubscription")
.where("userSubscription.startDate >= :startDate", { startDate: startOfYear })
.andWhere("userSubscription.startDate <= :endDate", { endDate: endOfYear })
.andWhere("userSubscription.status = :status", { status: SubscriptionStatus.ACTIVE })
.getCount();
}
async getPlansSalesCountByMonthsInYear() {
const now = new Date();
const startOfYear = new Date(now.getFullYear(), 0, 1);
const endOfYear = new Date(now.getFullYear(), 11, 31);
const sales = await this.userSubscriptionsRepository
.createQueryBuilder("userSubscription")
.select("EXTRACT(MONTH FROM userSubscription.startDate)", "month")
.addSelect("COUNT(*)", "count")
.where("userSubscription.startDate >= :startDate", { startDate: startOfYear })
.andWhere("userSubscription.startDate <= :endDate", { endDate: endOfYear })
.andWhere("userSubscription.status = :status", { status: SubscriptionStatus.ACTIVE })
.groupBy("EXTRACT(MONTH FROM userSubscription.startDate)")
.orderBy("month", "ASC")
.getRawMany();
const salesByMonth = Array(12).fill(0);
sales.forEach((sale) => {
const month = parseInt(sale.month) - 1;
salesByMonth[month] = parseInt(sale.count);
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,
},
});
return salesByMonth;
if (!userSubscriptions.length) throw new ForbiddenException(SubscriptionMessage.USER_DOES_NOT_HAVE_ACCESS);
return { workspaces: userSubscriptions };
}
async getPlansSalesCountByDaysInMonth() {
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const sales = await this.userSubscriptionsRepository
.createQueryBuilder("userSubscription")
.select("DATE(userSubscription.startDate)", "date")
.addSelect("COUNT(*)", "count")
.where("userSubscription.startDate >= :startDate", { startDate: startOfMonth })
.andWhere("userSubscription.startDate <= :endDate", { endDate: endOfMonth })
.andWhere("userSubscription.status = :status", { status: SubscriptionStatus.ACTIVE })
.groupBy("DATE(userSubscription.startDate)")
.orderBy("date", "ASC")
.getRawMany();
const daysInMonth = endOfMonth.getDate();
const salesByDay = Array(daysInMonth).fill(0);
sales.forEach((sale) => {
const day = new Date(sale.date).getDate() - 1;
salesByDay[day] = parseInt(sale.count);
});
return salesByDay;
//************************************ */
async addProvisioningJob(userSubscription: UserSubscription, plan: SubscriptionPlan, user: User) {
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,
},
{
priority: SUBSCRIPTIONS.PROVISIONING_JOB_PRIORITY,
delay: SUBSCRIPTIONS.PROVISIONING_JOB_DELAY,
attempts: SUBSCRIPTIONS.PROVISIONING_JOB_ATTEMPTS,
backoff: { type: "exponential", delay: SUBSCRIPTIONS.PROVISIONING_JOB_BACKOFF },
},
);
}
async getPlansSalesCountByDaysInWeek() {
const startOfWeek = dayjs().startOf("week").toDate();
const endOfWeek = dayjs().endOf("week").toDate();
const sales = await this.userSubscriptionsRepository
.createQueryBuilder("userSubscription")
.select("DATE(userSubscription.startDate)", "date")
.addSelect("COUNT(*)", "count")
.where("userSubscription.startDate >= :startDate", { startDate: startOfWeek })
.andWhere("userSubscription.startDate <= :endDate", { endDate: endOfWeek })
.andWhere("userSubscription.status = :status", { status: SubscriptionStatus.ACTIVE })
.groupBy("DATE(userSubscription.startDate)")
.orderBy("date", "ASC")
.getRawMany();
const salesByDay = Array(7).fill(0);
sales.forEach((sale) => {
const day = (dayjs(sale.date).day() + 1) % 7;
salesByDay[day] = parseInt(sale.count);
});
return salesByDay;
}
async getSubscriptionGrowthRate() {
const currentYear = new Date().getFullYear();
const lastYear = currentYear - 1;
const currentYearCount = await this.userSubscriptionsRepository
.createQueryBuilder("userSubscription")
.where("EXTRACT(YEAR FROM userSubscription.startDate) = :year", { year: currentYear })
.andWhere("userSubscription.status = :status", { status: SubscriptionStatus.ACTIVE })
.getCount();
const lastYearCount = await this.userSubscriptionsRepository
.createQueryBuilder("userSubscription")
.where("EXTRACT(YEAR FROM userSubscription.startDate) = :year", { year: lastYear })
.andWhere("userSubscription.status = :status", { status: SubscriptionStatus.ACTIVE })
.getCount();
if (lastYearCount === 0) return 100;
const growthRate = ((currentYearCount - lastYearCount) / lastYearCount) * 100;
return Math.round(growthRate * 100) / 100;
}
//************************************ */
}
@@ -93,4 +93,10 @@ export class SubscriptionsController {
removeQuickAccess(@Param() paramDto: ParamDto, @UserDec("id") userId: string) {
return this.userQuickAccessService.removeQuickAccess(userId, paramDto.id);
}
@ApiOperation({ summary: "Check if user has access to a service and return all their workspaces for that service (internal use)" })
@Get("workspaces/:serviceId")
async getUserServiceAccess(@Param() serviceIdParamDto: ServiceIdParamDto, @UserDec("id") userId: string) {
return this.subscriptionService.getUserSubscriptionsForService(userId, serviceIdParamDto.serviceId);
}
}
@@ -1,6 +1,8 @@
import { BullModule } from "@nestjs/bullmq";
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { SUBSCRIPTIONS } from "./constants";
import { SubscriptionPlan } from "./entities/subscription.entity";
import { UserSubscription } from "./entities/user-subscription.entity";
import { SubscriptionsService } from "./providers/subscriptions.service";
@@ -18,6 +20,10 @@ import { UserQuickAccessRepository } from "./repositories/users-quick-access.rep
@Module({
imports: [
TypeOrmModule.forFeature([SubscriptionPlan, UserSubscription, UserQuickAccess]),
BullModule.registerQueue({
name: SUBSCRIPTIONS.PROVISIONING_QUEUE_NAME,
prefix: SUBSCRIPTIONS.PROVISIONING_QUEUE_PREFIX,
}),
DanakServicesModule,
UsersModule,
WalletsModule,
+2 -1
View File
@@ -154,7 +154,7 @@ export class EmailService {
}
}
async sendAdminNotificationEmail(data: { userEmail: string; title: string; message: string }) {
async sendAdminNotificationEmail(data: { userEmail: string; title: string; message: string; fullName?: string }) {
const emailData = {
to: data.userEmail,
subject: data.title,
@@ -162,6 +162,7 @@ export class EmailService {
context: {
title: data.title,
message: data.message,
fullName: data.fullName,
},
};