diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index b2c3b06..a1bd16a 100644 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -64,6 +64,7 @@ export const enum UserMessage { USERNAME_SHOULD_BE_BETWEEN_3_AND_50 = "نام کاربری باید بین ۳ تا ۵۰ کاراکتر باشد", USER_GROUP_NOT_FOUND = "گروه کاربری یافت نشد", USER_GROUP_CREATED = "گروه کاربری با موفقیت ایجاد شد", + USER_ID_SHOULD_BE_A_UUID = "شناسه کاربر باید یک UUID باشد", } export const enum CommonMessage { @@ -124,6 +125,7 @@ export const enum ServiceMessage { SERVICE_NOT_EXIST = "سرویس مورد نظر یافت نشد", NAME_EXIST = "سرویس با این نام قبلا ثبت شده است", SERVICE_NOT_FOUND_BY_ID = "سرویسی با این شناسه یافت نشد", + SERVICE_ID_SHOULD_BE_A_UUID = "شناسه سرویس باید یک UUID باشد", } export const enum AnnouncementMessage { @@ -201,6 +203,8 @@ export const enum WalletMessage { TRANSFER_METHOD_REQUIRED = "روش انتفال اجباری است", GATEWAY_ID_SHOULD_BE_UUID = "آیدی درگاه پرداخت باید UUID باشد", DEPOSIT_WALLET_TRANSFER = "شارژ کیف پول از طریق انتقال بانکی", + INSUFFICIENT_BALANCE = "موجودی کیف پول کافی نیست", + SUBSCRIPTION_WALLET_TRANSFER = "پرداخت اشتراک از طریق کیف پول", } export const enum PaymentMessage { @@ -290,6 +294,25 @@ export const enum SubscriptionMessage { CREATED = "اشتراک با موفقیت ایجاد شد", NOT_FOUND = "اشتراک یافت نشد", UPDATED = "اشتراک با موفقیت به روز رسانی شد", + PLAN_ID_REQUIRED = "شناسه اشتراک مورد نیاز است", + PLAN_ID_SHOULD_BE_UUID = "شناسه اشتراک باید یک UUID معتبر باشد", + SUBSCRIBED = "اشتراک شما با موفقیت ثبت شد", +} + +export const enum InvoiceMessage { + COUNT_REQUIRED = "تعداد محصول مورد نیاز است", + COUNT_MUST_BE_A_INT = "تعداد محصول باید یک عدد صحیح باشد", + UNIT_PRICE_REQUIRED = "قیمت واحد محصول مورد نیاز است", + UNIT_PRICE_MUST_BE_A_INT = "قیمت واحد محصول باید یک عدد صحیح باشد", + DISCOUNT_REQUIRED = "تخفیف محصول مورد نیاز است", + DISCOUNT_MUST_BE_A_INT = "تخفیف محصول باید یک عدد صحیح باشد", + NAME_REQUIRED = "نام محصول مورد نیاز است", + NAME_MUST_BE_A_STRING = "نام محصول باید یک رشته باشد", + NAME_LENGTH = "نام محصول باید بین ۳ تا ۱۰۰ کاراکتر باشد", + USER_ID_REQUIRED = "شناسه کاربر مورد نیاز است", + USER_ID_SHOULD_BE_A_UUID = "شناسه کاربر باید یک UUID معتبر باشد", + CREATED = "فاکتور با موفقیت ایجاد شد", + SEARCH_QUERY_MUST_BE_A_STRING = "رشته جستجو باید یک رشته باشد", } export const enum LearningMessage { diff --git a/src/modules/danak-services/danak-services.controller.ts b/src/modules/danak-services/danak-services.controller.ts index 38877fb..6c27e98 100644 --- a/src/modules/danak-services/danak-services.controller.ts +++ b/src/modules/danak-services/danak-services.controller.ts @@ -12,16 +12,16 @@ import { Roles } from "../../common/decorators/roles.decorator"; import { ParamDto } from "../../common/DTO/param.dto"; import { RoleEnum } from "../users/enums/role.enum"; -@Controller("services") +@Controller("danak-services") @ApiTags("Danak-Services") export class DanakServicesController { constructor(private readonly danakServicesService: DanakServicesService) {} - // + //------------------------ service categories ------------------------ @AuthGuards() @Roles(RoleEnum.ADMIN) @ApiOperation({ summary: "Create a new service category => admin route" }) - @Post("category") + @Post("categories") createCategory(@Body() createDto: CreateCategoryDto) { return this.danakServicesService.createCategory(createDto); } @@ -29,7 +29,7 @@ export class DanakServicesController { @AuthGuards() @Roles(RoleEnum.ADMIN) @ApiOperation({ summary: "Get all service categories => admin route" }) - @Get("category") + @Get("categories") getCategories(@Query() queryDto: CategorySearchQueryDto) { return this.danakServicesService.getCategories(queryDto); } @@ -38,11 +38,27 @@ export class DanakServicesController { @Roles(RoleEnum.ADMIN) @ApiOperation({ summary: "Get all service categories => admin route" }) @Pagination() - @Get("category-list") + @Get("categories/list") getCategoryList(@Query() queryDto: CategoryListSearchQueryDto) { return this.danakServicesService.getCategoryList(queryDto); } + @AuthGuards() + @Roles(RoleEnum.USER) + @ApiOperation({ summary: "Get all service categories user side" }) + @Get("categories/public") + getCategoriesUserSide() { + return this.danakServicesService.getCategoriesUserSide(); + } + + @AuthGuards() + @Roles(RoleEnum.USER) + @ApiOperation({ summary: "get category services with category id" }) + @Get("categories/:id/services") + getCategoryServices(@Param() paramDto: ParamDto) { + return this.danakServicesService.getCategoryServices(paramDto.id); + } + @AuthGuards() @Roles(RoleEnum.ADMIN) @ApiOperation({ summary: "toggle status of categories => admin route" }) @@ -51,7 +67,7 @@ export class DanakServicesController { toggleCategoryStatus(@Param() paramDto: ParamDto) { return this.danakServicesService.toggleCategoryStatus(paramDto); } - + //-------------------- service management -------------------------- @AuthGuards() @Roles(RoleEnum.ADMIN) @ApiOperation({ summary: "create new danak services => admin route" }) @@ -63,7 +79,7 @@ export class DanakServicesController { @AuthGuards() @ApiOperation({ summary: "get all danak services ==> admin route" }) @Roles(RoleEnum.ADMIN) - @Get("list") + @Get() getServices(@Query() queryDto: DanakServicesSearchQueryDto) { return this.danakServicesService.getServicesList(queryDto); } @@ -77,6 +93,22 @@ export class DanakServicesController { return this.danakServicesService.toggleServiceStatus(paramDto); } + @ApiOperation({ summary: "get services for user side" }) + @AuthGuards() + @Roles(RoleEnum.USER) + @Get("suggested") + getDanakSuggestServices() { + return this.danakServicesService.getDanakSuggestServices(); + } + + @ApiOperation({ summary: "get danak service by id" }) + @AuthGuards() + @Roles(RoleEnum.USER) + @Get(":id") + getDanakServiceById(@Param() paramDto: ParamDto) { + return this.danakServicesService.getDanakServiceByID(paramDto.id); + } + // @AuthGuards() // @Roles(RoleEnum.USER) // @ApiOperation({ summary: "get all user purchased danak services" }) diff --git a/src/modules/danak-services/providers/danak-services.service.ts b/src/modules/danak-services/providers/danak-services.service.ts index b3896d9..0f8bac1 100644 --- a/src/modules/danak-services/providers/danak-services.service.ts +++ b/src/modules/danak-services/providers/danak-services.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, Injectable } from "@nestjs/common"; -import { Brackets, FindOptionsWhere, IsNull } from "typeorm"; +import { FindOptionsWhere, IsNull } from "typeorm"; import { ParamDto } from "../../../common/DTO/param.dto"; import { CategoryMessage, CommonMessage, ServiceMessage } from "../../../common/enums/message.enum"; @@ -79,33 +79,56 @@ export class DanakServicesService { } /******************************************** */ - // async getUserServices(userId: string) { - // const userServices = await this.danakServicesRepository.find({ - // where: { users: { id: userId } }, - // }); + async getCategoriesUserSide() { + const categories = await this.danakServicesCategoryRepository.find({ + where: { isActive: true }, + order: { createdAt: "DESC" }, + }); + return { + categories, + }; + } + /******************************************** */ + + async getCategoryServices(categoryId: string) { + const category = await this.danakServicesCategoryRepository.findOne({ + where: { id: categoryId }, + relations: { + danakService: true, + }, + order: { createdAt: "DESC" }, + }); + if (!category) throw new BadRequestException(CategoryMessage.CATEGORY_NOT_EXIST); + return { + category, + }; + } - // return { - // userServices, - // }; - // } /******************************************** */ async toggleCategoryStatus(paramDto: ParamDto) { const category = await this.danakServicesCategoryRepository.findOneById(paramDto.id); if (!category) throw new BadRequestException(CategoryMessage.CATEGORY_NOT_EXIST); + // category.isActive = !category.isActive; + // await this.danakServicesCategoryRepository.save(category); + // return { message: CommonMessage.UPDATE_SUCCESS, isActive: category.isActive, }; } + /******************************************** */ async toggleServiceStatus(paramDto: ParamDto) { const service = await this.danakServicesRepository.findServiceById(paramDto.id); if (!service) throw new BadRequestException(ServiceMessage.SERVICE_NOT_EXIST); + // service.isActive = !service.isActive; + // await this.danakServicesRepository.save(service); + // return { message: CommonMessage.UPDATE_SUCCESS, isActive: service.isActive, @@ -133,42 +156,30 @@ export class DanakServicesService { /******************************************** */ async getServicesList(queryDto: DanakServicesSearchQueryDto) { - const { limit, skip } = PaginationUtils(queryDto); + return this.danakServicesRepository.getServicesForAdmin(queryDto); + } - const queryBuilder = this.danakServicesRepository - .createQueryBuilder("service") - .leftJoinAndSelect("service.category", "category") - .orderBy("service.createdAt", "DESC") - .skip(skip) - .take(limit); - - if (queryDto.isActive) { - queryBuilder.andWhere("service.isActive = :isActive", { isActive: queryDto.isActive === 1 }); - } - - if (queryDto.isDanakSuggest) { - queryBuilder.andWhere("service.isDanakSuggest = :isDanakSuggest", { isDanakSuggest: queryDto.isDanakSuggest === 1 }); - } - - if (queryDto.categoryId) { - queryBuilder.andWhere("service.categoryId = :categoryId", { categoryId: queryDto.categoryId }); - } - - if (queryDto.q) { - queryBuilder.andWhere( - new Brackets((qb) => { - qb.where("service.name LIKE :q", { q: `%${queryDto.q}%` }) - .orWhere("service.description LIKE :q", { q: `%${queryDto.q}%` }) - .orWhere("service.softwareLanguage LIKE :q", { q: `%${queryDto.q}%` }) - .orWhere("service.author LIKE :q", { q: `%${queryDto.q}%` }); - }), - ); - } - const [services, count] = await queryBuilder.getManyAndCount(); + /******************************************** */ + async getDanakSuggestServices() { + const danakServices = await this.danakServicesRepository.find({ + where: { isDanakSuggest: true, isActive: true }, + order: { createdAt: "DESC" }, + }); return { - services, - count, + danakServices, + }; + } + /******************************************** */ + + async getDanakServiceByID(serviceId: string) { + const danakService = await this.danakServicesRepository.findOne({ + where: { id: serviceId, isActive: true }, + relations: { images: true, subscriptionPlans: true }, + }); + if (!danakService) throw new BadRequestException(ServiceMessage.SERVICE_NOT_FOUND_BY_ID); + return { + danakService, }; } diff --git a/src/modules/danak-services/repositories/danak-services.repository.ts b/src/modules/danak-services/repositories/danak-services.repository.ts index a494033..19a32dc 100644 --- a/src/modules/danak-services/repositories/danak-services.repository.ts +++ b/src/modules/danak-services/repositories/danak-services.repository.ts @@ -1,7 +1,9 @@ import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; +import { Brackets, Repository } from "typeorm"; +import { PaginationUtils } from "../../utils/providers/pagination.utils"; +import { DanakServicesSearchQueryDto } from "../DTO/danak-services-search-query.dto"; import { DanakService } from "../entities/danak-service.entity"; @Injectable() @@ -17,4 +19,43 @@ export class DanakServicesRepository extends Repository { async findServiceById(id: string): Promise { return this.findOneBy({ id }); } + + async getServicesForAdmin(queryDto: DanakServicesSearchQueryDto) { + const { limit, skip } = PaginationUtils(queryDto); + + const queryBuilder = this.createQueryBuilder("service") + .leftJoinAndSelect("service.category", "category") + .orderBy("service.createdAt", "DESC") + .skip(skip) + .take(limit); + + if (queryDto.isActive) { + queryBuilder.andWhere("service.isActive = :isActive", { isActive: queryDto.isActive === 1 }); + } + + if (queryDto.isDanakSuggest) { + queryBuilder.andWhere("service.isDanakSuggest = :isDanakSuggest", { isDanakSuggest: queryDto.isDanakSuggest === 1 }); + } + + if (queryDto.categoryId) { + queryBuilder.andWhere("service.categoryId = :categoryId", { categoryId: queryDto.categoryId }); + } + + if (queryDto.q) { + queryBuilder.andWhere( + new Brackets((qb) => { + qb.where("service.name LIKE :q", { q: `%${queryDto.q}%` }) + .orWhere("service.description LIKE :q", { q: `%${queryDto.q}%` }) + .orWhere("service.softwareLanguage LIKE :q", { q: `%${queryDto.q}%` }) + .orWhere("service.author LIKE :q", { q: `%${queryDto.q}%` }); + }), + ); + } + const [services, count] = await queryBuilder.getManyAndCount(); + + return { + services, + count, + }; + } } diff --git a/src/modules/invoices/DTO/create-invoice.dto.ts b/src/modules/invoices/DTO/create-invoice.dto.ts new file mode 100644 index 0000000..1019c75 --- /dev/null +++ b/src/modules/invoices/DTO/create-invoice.dto.ts @@ -0,0 +1,46 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { ArrayMinSize, IsInt, IsNotEmpty, IsOptional, IsString, IsUUID, Length, ValidateNested } from "class-validator"; + +import { InvoiceMessage } from "../../../common/enums/message.enum"; + +export class InvoiceItemDto { + @IsNotEmpty({ message: InvoiceMessage.NAME_REQUIRED }) + @IsString({ message: InvoiceMessage.NAME_MUST_BE_A_STRING }) + @Length(1, 150, { message: InvoiceMessage.NAME_LENGTH }) + @ApiProperty({ type: String, description: "Item name" }) + name: string; + + @IsNotEmpty({ message: InvoiceMessage.COUNT_REQUIRED }) + @IsInt({ message: InvoiceMessage.COUNT_MUST_BE_A_INT }) + @ApiProperty({ type: Number, description: "Item count", example: 2 }) + count: number; + + @IsNotEmpty({ message: InvoiceMessage.UNIT_PRICE_REQUIRED }) + @IsInt({ message: InvoiceMessage.UNIT_PRICE_MUST_BE_A_INT }) + @ApiProperty({ description: "Item unit price", example: 100_000 }) + unitPrice: number; + + @IsOptional() + @IsNotEmpty({ message: InvoiceMessage.DISCOUNT_REQUIRED }) + @IsInt({ message: InvoiceMessage.DISCOUNT_MUST_BE_A_INT }) + @ApiProperty({ description: "Item discount", example: 10 }) + discount: number; +} + +export class CreateInvoiceDto { + @IsNotEmpty({ message: InvoiceMessage.USER_ID_REQUIRED }) + @IsUUID("4", { message: InvoiceMessage.USER_ID_SHOULD_BE_A_UUID }) + @ApiProperty({ description: "User id", example: "123e4567-e89b-12d3-a456-426614174000" }) + userId: string; + + @ApiProperty({ + type: [InvoiceItemDto], + description: "Invoice items", + example: [{ name: "item1", count: 2, unitPrice: 100_000, discount: 10 }], + }) + @ValidateNested({ each: true }) + @ArrayMinSize(1) + @Type(() => InvoiceItemDto) + items: InvoiceItemDto[]; +} diff --git a/src/modules/invoices/DTO/invoices-search-query.dto.ts b/src/modules/invoices/DTO/invoices-search-query.dto.ts new file mode 100644 index 0000000..b932a6f --- /dev/null +++ b/src/modules/invoices/DTO/invoices-search-query.dto.ts @@ -0,0 +1,28 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsEnum, IsOptional, IsString, IsUUID } from "class-validator"; + +import { PaginationDto } from "../../../common/DTO/pagination.dto"; +import { InvoiceMessage, ServiceMessage, UserMessage } from "../../../common/enums/message.enum"; +import { InvoiceStatus } from "../enums/invoice-status.enum"; + +export class InvoicesSearchQueryDto extends PaginationDto { + @IsOptional() + @IsString({ message: InvoiceMessage.SEARCH_QUERY_MUST_BE_A_STRING }) + @ApiPropertyOptional({ description: "search query", example: "search query" }) + q: string; + + @IsOptional() + @IsUUID("4", { message: ServiceMessage.SERVICE_ID_SHOULD_BE_A_UUID }) + @ApiPropertyOptional({ description: "service id", example: "123e4567-e89b-12d3-a456-426614174000" }) + serviceId: string; + + @IsOptional() + @IsUUID("4", { message: UserMessage.USER_ID_SHOULD_BE_A_UUID }) + @ApiPropertyOptional({ description: "user id", example: "123e4567-e89b-12d3-a456-426614174000" }) + userId: string; + + @IsOptional() + @IsEnum(InvoiceStatus) + @ApiPropertyOptional({ enum: InvoiceStatus, description: "invoice status", example: InvoiceStatus.PAID }) + status: InvoiceStatus; +} diff --git a/src/modules/invoices/entities/invoice-item.entity.ts b/src/modules/invoices/entities/invoice-item.entity.ts new file mode 100644 index 0000000..15fc85d --- /dev/null +++ b/src/modules/invoices/entities/invoice-item.entity.ts @@ -0,0 +1,32 @@ +// eslint-disable-next-line import/no-named-as-default +import Decimal from "decimal.js"; +import { Column, Entity, ManyToOne } from "typeorm"; + +import { Invoice } from "./invoice.entity"; +import { BaseEntity } from "../../../common/entities/base.entity"; +import { DecimalTransformer } from "../../../common/transformers/decimal.transformer"; +import { SubscriptionPlan } from "../../subscriptions/entities/subscription.entity"; + +@Entity() +export class InvoiceItem extends BaseEntity { + @ManyToOne(() => Invoice, (invoice) => invoice.items, { onDelete: "CASCADE" }) + invoice: Invoice; + + @Column({ type: "varchar", length: 150, nullable: false }) + name: string; + + @Column({ type: "int", nullable: false }) + count: number; + + @Column({ type: "decimal", precision: 16, scale: 2, nullable: false, transformer: new DecimalTransformer() }) + unitPrice: Decimal; + + @Column({ type: "int", nullable: false }) + discount: number; + + @Column({ type: "decimal", precision: 16, scale: 2, nullable: false, transformer: new DecimalTransformer() }) + totalPrice: Decimal; + + @ManyToOne(() => SubscriptionPlan, { nullable: true, onDelete: "RESTRICT" }) + subscriptionPlan: SubscriptionPlan | null; +} diff --git a/src/modules/invoices/entities/invoice.entity.ts b/src/modules/invoices/entities/invoice.entity.ts index 5e7a7c4..0e0864f 100644 --- a/src/modules/invoices/entities/invoice.entity.ts +++ b/src/modules/invoices/entities/invoice.entity.ts @@ -1,7 +1,10 @@ -import { Column, Entity, ManyToOne } from "typeorm"; +// eslint-disable-next-line import/no-named-as-default +import Decimal from "decimal.js"; +import { Column, Entity, ManyToOne, OneToMany } from "typeorm"; +import { InvoiceItem } from "./invoice-item.entity"; import { BaseEntity } from "../../../common/entities/base.entity"; -import { SubscriptionPlan } from "../../subscriptions/entities/subscription.entity"; +import { DecimalTransformer } from "../../../common/transformers/decimal.transformer"; import { User } from "../../users/entities/user.entity"; import { InvoiceStatus } from "../enums/invoice-status.enum"; @@ -10,15 +13,25 @@ export class Invoice extends BaseEntity { @ManyToOne(() => User, (user) => user.invoices, { nullable: true, onDelete: "RESTRICT" }) user: User; - @ManyToOne(() => SubscriptionPlan, { nullable: true, onDelete: "SET NULL" }) - subscriptionPlan?: SubscriptionPlan; - - @Column({ type: "decimal", precision: 10, scale: 2, nullable: false }) - amount: number; + @Column({ type: "decimal", precision: 16, scale: 2, nullable: false, transformer: new DecimalTransformer() }) + totalPrice: Decimal; @Column({ type: "enum", enum: InvoiceStatus, default: InvoiceStatus.PENDING }) status: InvoiceStatus; + @Column({ type: "timestamp", nullable: false }) + dueDate: Date; + @Column({ type: "timestamp", nullable: true }) - paidAt?: Date; + paidAt: Date | null; + + @Column({ type: "int", default: 0 }) + tax: number; + + @OneToMany(() => InvoiceItem, (invoiceItem) => invoiceItem.invoice, { cascade: true }) + items: InvoiceItem[]; + + get isOverdue(): boolean { + return this.status === InvoiceStatus.PENDING && new Date() > this.dueDate; + } } diff --git a/src/modules/invoices/enums/invoice-status.enum.ts b/src/modules/invoices/enums/invoice-status.enum.ts index 5fb5e40..7e071ee 100644 --- a/src/modules/invoices/enums/invoice-status.enum.ts +++ b/src/modules/invoices/enums/invoice-status.enum.ts @@ -1,5 +1,6 @@ export enum InvoiceStatus { PENDING = "PENDING", PAID = "PAID", - CANCELED = "CANCELED", + EXPIRED = "EXPIRED", + CANCELLED = "CANCELLED", } diff --git a/src/modules/invoices/invoices.controller.ts b/src/modules/invoices/invoices.controller.ts index cfe4b97..e4697f0 100644 --- a/src/modules/invoices/invoices.controller.ts +++ b/src/modules/invoices/invoices.controller.ts @@ -1,4 +1,32 @@ -import { Controller } from "@nestjs/common"; +import { Body, Controller, Get, Post, Query } from "@nestjs/common"; +import { ApiOperation } from "@nestjs/swagger"; + +import { CreateInvoiceDto } from "./DTO/create-invoice.dto"; +import { InvoicesSearchQueryDto } from "./DTO/invoices-search-query.dto"; +import { InvoicesService } from "./providers/invoices.service"; +import { AuthGuards } from "../../common/decorators/auth-guard.decorator"; +import { Pagination } from "../../common/decorators/pagination.decorator"; +import { Roles } from "../../common/decorators/roles.decorator"; +import { RoleEnum } from "../users/enums/role.enum"; @Controller("invoices") -export class InvoicesController {} +export class InvoicesController { + constructor(private readonly invoiceService: InvoicesService) {} + + @ApiOperation({ summary: "create an invoice ==> admin route" }) + @AuthGuards() + @Roles(RoleEnum.ADMIN) + @Post() + createInvoice(@Body() createDto: CreateInvoiceDto) { + return this.invoiceService.createInvoiceAdmin(createDto); + } + + @ApiOperation({ summary: "get all invoices ==> admin route" }) + @AuthGuards() + @Pagination() + @Roles(RoleEnum.ADMIN) + @Get() + getInvoices(@Query() queryDto: InvoicesSearchQueryDto) { + return this.invoiceService.getInvoices(queryDto); + } +} diff --git a/src/modules/invoices/invoices.module.ts b/src/modules/invoices/invoices.module.ts index 6faf6f7..d0ba06f 100644 --- a/src/modules/invoices/invoices.module.ts +++ b/src/modules/invoices/invoices.module.ts @@ -1,13 +1,14 @@ import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; +import { InvoiceItem } from "./entities/invoice-item.entity"; import { Invoice } from "./entities/invoice.entity"; import { InvoicesController } from "./invoices.controller"; import { InvoicesService } from "./providers/invoices.service"; import { InvoicesRepository } from "./repositories/invoices.repository"; @Module({ - imports: [TypeOrmModule.forFeature([Invoice])], + imports: [TypeOrmModule.forFeature([Invoice, InvoiceItem])], providers: [InvoicesService, InvoicesRepository], controllers: [InvoicesController], exports: [InvoicesService], diff --git a/src/modules/invoices/providers/invoices.service.ts b/src/modules/invoices/providers/invoices.service.ts index ed02092..4d6f9ac 100644 --- a/src/modules/invoices/providers/invoices.service.ts +++ b/src/modules/invoices/providers/invoices.service.ts @@ -1,4 +1,117 @@ import { Injectable } from "@nestjs/common"; +// eslint-disable-next-line import/no-named-as-default +import Decimal from "decimal.js"; +import { QueryRunner } from "typeorm"; + +import { InvoiceMessage } from "../../../common/enums/message.enum"; +import { SubscriptionPlan } from "../../subscriptions/entities/subscription.entity"; +import { PaginationUtils } from "../../utils/providers/pagination.utils"; +import { CreateInvoiceDto } from "../DTO/create-invoice.dto"; +import { InvoicesSearchQueryDto } from "../DTO/invoices-search-query.dto"; +import { Invoice } from "../entities/invoice.entity"; +import { InvoiceStatus } from "../enums/invoice-status.enum"; +// import { InvoiceItemsRepository } from "../repositories/invoice-items.repository"; +import { InvoicesRepository } from "../repositories/invoices.repository"; @Injectable() -export class InvoicesService {} +export class InvoicesService { + constructor( + private readonly invoiceRepository: InvoicesRepository, + // private readonly invoiceItemsRepository: InvoiceItemsRepository, + ) {} + ///********************************** */ + async createInvoiceAdmin(createDto: CreateInvoiceDto) { + // + const invoiceItems = createDto.items.map((item) => { + return { + name: item.name, + count: item.count, + unitPrice: item.unitPrice, + discount: item.discount || 0, + totalPrice: item.unitPrice * item.count - (item.unitPrice * item.count * item.discount) / 100, + }; + }); + + const totalPrice = invoiceItems.reduce((sum, item) => new Decimal(item.totalPrice).add(sum), new Decimal(0)); + const tax = totalPrice.mul(0.1); + // + const dueDate = new Date(); + dueDate.setDate(dueDate.getDate() + 5); + // + const invoice = this.invoiceRepository.create({ + user: { id: createDto.userId }, + totalPrice, + items: invoiceItems, + tax: tax.toNumber(), + dueDate, + }); + // + await this.invoiceRepository.save(invoice); + //TODO: notify user + + return { + message: InvoiceMessage.CREATED, + invoice, + }; + } + ///********************************** */ + async createInvoiceForSubscription(userId: string, plan: SubscriptionPlan, dueDate: Date, queryRunner: QueryRunner) { + const invoiceItems = [{ name: plan.name, count: 1, unitPrice: plan.price, discount: 0, subscriptionPlan: plan }]; + + const invoice = queryRunner.manager.create(Invoice, { + totalPrice: plan.price, + user: { id: userId }, + status: InvoiceStatus.PAID, + paidAt: new Date(), + dueDate, + items: invoiceItems, + }); + + await queryRunner.manager.save(Invoice, invoice); + return invoice; + } + + ///********************************** */ + + async getInvoices(queryDto: InvoicesSearchQueryDto) { + const { limit, skip } = PaginationUtils(queryDto); + + const queryBuilder = this.invoiceRepository.createQueryBuilder("invoice"); + + queryBuilder + .leftJoinAndSelect("invoice.user", "user") + .leftJoinAndSelect("invoice.items", "items") + .leftJoinAndSelect("items.subscriptionPlan", "subscriptionPlan"); + + if (queryDto.q) { + queryBuilder + .orWhere("user.firstName ILIKE :search", { search: `%${queryDto.q}%` }) + .orWhere("user.lastName ILIKE :search", { search: `%${queryDto.q}%` }) + .orWhere("user.email ILIKE :search", { search: `%${queryDto.q}%` }); + } + + if (queryDto.status) { + queryBuilder.andWhere("invoice.status = :status", { status: queryDto.status }); + } + + if (queryDto.userId) { + queryBuilder.andWhere("invoice.user.id = :userId", { userId: queryDto.userId }); + } + + if (queryDto.serviceId) { + queryBuilder.andWhere("subscriptionPlan.serviceId = :serviceId", { serviceId: queryDto.serviceId }); + } + + queryBuilder.orderBy("createdAt", "DESC"); + queryBuilder.skip(skip); + queryBuilder.take(limit); + + const [invoices, count] = await queryBuilder.getManyAndCount(); + + return { + invoices, + count, + paginate: true, + }; + } +} diff --git a/src/modules/invoices/repositories/invoice-items.repository.ts b/src/modules/invoices/repositories/invoice-items.repository.ts new file mode 100644 index 0000000..0870426 --- /dev/null +++ b/src/modules/invoices/repositories/invoice-items.repository.ts @@ -0,0 +1,12 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { InvoiceItem } from "../entities/invoice-item.entity"; + +@Injectable() +export class InvoiceItemsRepository extends Repository { + constructor(@InjectRepository(InvoiceItem) invoiceItemsRepository: Repository) { + super(invoiceItemsRepository.target, invoiceItemsRepository.manager, invoiceItemsRepository.queryRunner); + } +} diff --git a/src/modules/subscriptions/DTO/service-id.param.dto.ts b/src/modules/subscriptions/DTO/service-id.param.dto.ts new file mode 100644 index 0000000..d2d74dc --- /dev/null +++ b/src/modules/subscriptions/DTO/service-id.param.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty, IsUUID } from "class-validator"; + +import { CommonMessage } from "../../../common/enums/message.enum"; + +export class ServiceIdParamDto { + @IsNotEmpty({ message: CommonMessage.ID_REQUIRED }) + @IsUUID("4", { message: CommonMessage.ID_SHOULD_BE_UUID }) + @ApiProperty({ description: "Id of danak service", example: "8b1e8b1e-8b1e-8b1e-8b1e-8b1e8b1e8b1e" }) + serviceId: string; +} diff --git a/src/modules/subscriptions/DTO/subscribe-service.dto.ts b/src/modules/subscriptions/DTO/subscribe-service.dto.ts new file mode 100644 index 0000000..9b8da12 --- /dev/null +++ b/src/modules/subscriptions/DTO/subscribe-service.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty, IsUUID } from "class-validator"; + +import { SubscriptionMessage } from "../../../common/enums/message.enum"; + +export class SubscribeServiceDto { + @IsNotEmpty({ message: SubscriptionMessage.PLAN_ID_REQUIRED }) + @IsUUID(4, { message: SubscriptionMessage.PLAN_ID_SHOULD_BE_UUID }) + @ApiProperty({ description: "The plan id to subscribe to", example: "f7b3b3b3-7b3b-4b3b-8b3b-3b3b3b3b3b3b" }) + planId: string; +} diff --git a/src/modules/subscriptions/DTO/update-subscription.dto.ts b/src/modules/subscriptions/DTO/update-subscription.dto.ts new file mode 100644 index 0000000..455f2db --- /dev/null +++ b/src/modules/subscriptions/DTO/update-subscription.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from "@nestjs/swagger"; + +import { CreateSubscriptionPlanDto } from "./create-subscription.dto"; + +export class UpdateSubscriptionPlanDto extends PartialType(CreateSubscriptionPlanDto) {} diff --git a/src/modules/subscriptions/entities/user-subscription.entity.ts b/src/modules/subscriptions/entities/user-subscription.entity.ts index 1f435e2..9e17103 100644 --- a/src/modules/subscriptions/entities/user-subscription.entity.ts +++ b/src/modules/subscriptions/entities/user-subscription.entity.ts @@ -3,6 +3,7 @@ import { Column, Entity, ManyToOne } from "typeorm"; import { SubscriptionPlan } from "./subscription.entity"; import { BaseEntity } from "../../../common/entities/base.entity"; import { User } from "../../users/entities/user.entity"; +import { SubscriptionStatus } from "../enums/subscription-status.enum"; @Entity() export class UserSubscription extends BaseEntity { @@ -18,6 +19,9 @@ export class UserSubscription extends BaseEntity { @Column({ type: "timestamp", nullable: false }) endDate: Date; + @Column({ type: "enum", enum: SubscriptionStatus, default: SubscriptionStatus.PENDING }) + status: SubscriptionStatus; + @Column({ type: "boolean", default: false }) isCanceled: boolean; } diff --git a/src/modules/subscriptions/enums/subscription-status.enum.ts b/src/modules/subscriptions/enums/subscription-status.enum.ts new file mode 100644 index 0000000..1e2bc5f --- /dev/null +++ b/src/modules/subscriptions/enums/subscription-status.enum.ts @@ -0,0 +1,7 @@ +export enum SubscriptionStatus { + ACTIVE = "ACTIVE", + INACTIVE = "INACTIVE", + PENDING = "PENDING", + CANCELED = "CANCELED", + EXPIRED = "EXPIRED", +} diff --git a/src/modules/subscriptions/providers/subscriptions.service.ts b/src/modules/subscriptions/providers/subscriptions.service.ts index d1d246b..1f35c65 100644 --- a/src/modules/subscriptions/providers/subscriptions.service.ts +++ b/src/modules/subscriptions/providers/subscriptions.service.ts @@ -1,15 +1,33 @@ import { BadRequestException, Injectable } from "@nestjs/common"; +// eslint-disable-next-line import/no-named-as-default +import Decimal from "decimal.js"; +import { DataSource } from "typeorm"; -import { ServiceMessage, SubscriptionMessage } from "../../../common/enums/message.enum"; +import { ServiceMessage, SubscriptionMessage, WalletMessage } from "../../../common/enums/message.enum"; import { DanakServicesService } from "../../danak-services/providers/danak-services.service"; +import { InvoicesService } from "../../invoices/providers/invoices.service"; +import { UsersService } from "../../users/providers/users.service"; +import { Wallet } from "../../wallets/entities/wallet.entity"; +import { WalletsService } from "../../wallets/providers/wallets.service"; import { CreateSubscriptionPlanDto } from "../DTO/create-subscription.dto"; +import { SubscribeServiceDto } from "../DTO/subscribe-service.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 { SubscriptionsPlanRepository } from "../repositories/subscriptions.repository"; +// import { UserSubscriptionsRepository } from "../repositories/user-subscriptions.repository"; @Injectable() export class SubscriptionsService { constructor( private readonly subscriptionsPlanRepository: SubscriptionsPlanRepository, + // private readonly userSubscriptionsRepository: UserSubscriptionsRepository, + private readonly invoicesService: InvoicesService, + private readonly usersService: UsersService, + private readonly walletsService: WalletsService, private readonly danakServices: DanakServicesService, + private readonly dataSource: DataSource, ) {} //************************************ */ @@ -20,7 +38,7 @@ export class SubscriptionsService { const existSubscription = await this.subscriptionsPlanRepository.findOneByName(createDto.name); if (existSubscription) throw new BadRequestException(SubscriptionMessage.NAME_EXIST); - const subscription = this.subscriptionsPlanRepository.create(createDto); + const subscription = this.subscriptionsPlanRepository.create({ ...createDto, service: { id: createDto.serviceId } }); await this.subscriptionsPlanRepository.save(subscription); return { message: SubscriptionMessage.CREATED, @@ -28,21 +46,107 @@ export class SubscriptionsService { }; } //************************************ */ - async updateSubscriptionPlan(id: string, updateDto: CreateSubscriptionPlanDto) { - const danakService = await this.danakServices.findServiceById(updateDto.serviceId); - if (!danakService) throw new BadRequestException(ServiceMessage.SERVICE_NOT_FOUND_BY_ID); + async updateSubscriptionPlan(id: string, updateDto: UpdateSubscriptionPlanDto) { + if (updateDto.serviceId) { + const danakService = await this.danakServices.findServiceById(updateDto.serviceId); + if (!danakService) throw new BadRequestException(ServiceMessage.SERVICE_NOT_FOUND_BY_ID); + } const subscription = await this.subscriptionsPlanRepository.findOneBy({ id }); if (!subscription) throw new BadRequestException(SubscriptionMessage.NOT_FOUND); - const existSubscription = await this.subscriptionsPlanRepository.findOneByName(updateDto.name, id); - if (existSubscription) throw new BadRequestException(SubscriptionMessage.NAME_EXIST); + if (updateDto.name) { + const existSubscription = await this.subscriptionsPlanRepository.findOneByName(updateDto.name, id); + if (existSubscription) throw new BadRequestException(SubscriptionMessage.NAME_EXIST); + } await this.subscriptionsPlanRepository.update(id, updateDto); - await this.subscriptionsPlanRepository.save({ ...subscription, ...updateDto, service: danakService }); + await this.subscriptionsPlanRepository.save({ + ...subscription, + ...updateDto, + service: updateDto.serviceId ? { id: updateDto.serviceId } : subscription.service, + }); 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 getSubscriptionsPlans() { + const subscriptions = await this.subscriptionsPlanRepository.find({ relations: ["service"] }); + return { + subscriptions, + }; + } + + //************************************ */ + + async subscribeToPlan(serviceId: string, subscribeDto: SubscribeServiceDto, userId: string) { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + try { + 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, + }, + }); + + if (!plan) throw new BadRequestException(SubscriptionMessage.NOT_FOUND); + + const startDate = new Date(); + const endDate = new Date(); + endDate.setDate(startDate.getDate() + plan.duration); + // + const userSubscription = queryRunner.manager.create(UserSubscription, { user, plan, startDate, endDate }); + + await queryRunner.manager.save(UserSubscription, userSubscription); + + const userWallet = await this.walletsService.getWalletByUserId(user.id, queryRunner); + + if (userWallet.balance < plan.price) throw new BadRequestException(WalletMessage.INSUFFICIENT_BALANCE); + + // + userWallet.balance = new Decimal(userWallet.balance).sub(plan.price); + + await queryRunner.manager.save(Wallet, userWallet); + // + + await this.walletsService.createSubscriptionTransaction(plan.price, userWallet.id, queryRunner); + //TODO: need queue handling for notification + const invoiceDueDate = new Date(userSubscription.endDate); + invoiceDueDate.setDate(userSubscription.endDate.getDate() - 3); + + const invoice = await this.invoicesService.createInvoiceForSubscription(userId, plan, invoiceDueDate, queryRunner); + + userSubscription.status = SubscriptionStatus.ACTIVE; + + await queryRunner.manager.save(UserSubscription, userSubscription); + + await queryRunner.commitTransaction(); + return { + message: SubscriptionMessage.SUBSCRIBED, + userSubscription, + invoice, + }; + } catch (error) { + await queryRunner.rollbackTransaction(); + throw error; + } finally { + await queryRunner.release(); + } + } } diff --git a/src/modules/subscriptions/subscriptions.controller.ts b/src/modules/subscriptions/subscriptions.controller.ts index 734276f..eda22e3 100644 --- a/src/modules/subscriptions/subscriptions.controller.ts +++ b/src/modules/subscriptions/subscriptions.controller.ts @@ -1,10 +1,15 @@ -import { Body, Controller, Post } from "@nestjs/common"; +import { Body, Controller, Get, Param, Patch, Post } from "@nestjs/common"; import { ApiOperation } from "@nestjs/swagger"; import { CreateSubscriptionPlanDto } from "./DTO/create-subscription.dto"; +import { ServiceIdParamDto } from "./DTO/service-id.param.dto"; +import { SubscribeServiceDto } from "./DTO/subscribe-service.dto"; +import { UpdateSubscriptionPlanDto } from "./DTO/update-subscription.dto"; import { SubscriptionsService } from "./providers/subscriptions.service"; import { AuthGuards } from "../../common/decorators/auth-guard.decorator"; import { Roles } from "../../common/decorators/roles.decorator"; +import { UserDec } from "../../common/decorators/user.decorator"; +import { ParamDto } from "../../common/DTO/param.dto"; import { RoleEnum } from "../users/enums/role.enum"; @Controller("subscriptions") @@ -14,8 +19,40 @@ export class SubscriptionsController { @ApiOperation({ summary: "Create a subscription plan" }) @AuthGuards() @Roles(RoleEnum.ADMIN) - @Post("/plan") + @Post() createSubscription(@Body() createDto: CreateSubscriptionPlanDto) { return this.subscriptionService.createSubscriptionsPlan(createDto); } + + @ApiOperation({ summary: "get all subscription plans" }) + @AuthGuards() + @Roles(RoleEnum.ADMIN) + @Get() + getAllSubscriptions() { + return this.subscriptionService.getSubscriptionsPlans(); + } + + @ApiOperation({ summary: "get a subscription plan by id" }) + @AuthGuards() + @Roles(RoleEnum.ADMIN) + @Get(":id") + getSubscription(@Param() paramDto: ParamDto) { + return this.subscriptionService.getSubscriptionPlanById(paramDto.id); + } + + @ApiOperation({ summary: "update a subscription plan by id" }) + @AuthGuards() + @Roles(RoleEnum.ADMIN) + @Patch(":id") + updateSubscription(@Param() paramDto: ParamDto, @Body() updateDto: UpdateSubscriptionPlanDto) { + return this.subscriptionService.updateSubscriptionPlan(paramDto.id, updateDto); + } + + @ApiOperation({ summary: "subscribe to a service ==> user route" }) + @AuthGuards() + @Roles(RoleEnum.USER) + @Post(":serviceId/subscribe") + subscribe(@Param() paramDto: ServiceIdParamDto, @Body() subscribeDto: SubscribeServiceDto, @UserDec("id") userId: string) { + return this.subscriptionService.subscribeToPlan(paramDto.serviceId, subscribeDto, userId); + } } diff --git a/src/modules/subscriptions/subscriptions.module.ts b/src/modules/subscriptions/subscriptions.module.ts index 8136577..4c59ba6 100644 --- a/src/modules/subscriptions/subscriptions.module.ts +++ b/src/modules/subscriptions/subscriptions.module.ts @@ -8,9 +8,18 @@ import { SubscriptionsPlanRepository } from "./repositories/subscriptions.reposi import { UserSubscriptionsRepository } from "./repositories/user-subscriptions.repository"; import { SubscriptionsController } from "./subscriptions.controller"; import { DanakServicesModule } from "../danak-services/danak-services.module"; +import { InvoicesModule } from "../invoices/invoices.module"; +import { UsersModule } from "../users/users.module"; +import { WalletsModule } from "../wallets/wallets.module"; @Module({ - imports: [TypeOrmModule.forFeature([SubscriptionPlan, UserSubscription]), DanakServicesModule], + imports: [ + TypeOrmModule.forFeature([SubscriptionPlan, UserSubscription]), + DanakServicesModule, + UsersModule, + WalletsModule, + InvoicesModule, + ], providers: [SubscriptionsService, SubscriptionsPlanRepository, UserSubscriptionsRepository], controllers: [SubscriptionsController], exports: [SubscriptionsService], diff --git a/src/modules/users/providers/users.service.ts b/src/modules/users/providers/users.service.ts index 7f8a39c..06fad80 100644 --- a/src/modules/users/providers/users.service.ts +++ b/src/modules/users/providers/users.service.ts @@ -46,6 +46,13 @@ export class UsersService { if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); return { user }; } + /************************************************************ */ + + async findOneByIdWithQueryRunner(id: string, queryRunner: QueryRunner) { + const user = await queryRunner.manager.findOneBy(User, { id }); + if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); + return user; + } /************************************************************ */ diff --git a/src/modules/wallets/enums/transaction-type.enum.ts b/src/modules/wallets/enums/transaction-type.enum.ts index 5b6b66a..23f805e 100644 --- a/src/modules/wallets/enums/transaction-type.enum.ts +++ b/src/modules/wallets/enums/transaction-type.enum.ts @@ -1,4 +1,5 @@ export enum TransactionType { DEPOSIT = "DEPOSIT", WITHDRAWAL = "WITHDRAWAL", + DEBIT = "DEBIT", } diff --git a/src/modules/wallets/providers/wallets.service.ts b/src/modules/wallets/providers/wallets.service.ts index 09d7416..293a7c1 100644 --- a/src/modules/wallets/providers/wallets.service.ts +++ b/src/modules/wallets/providers/wallets.service.ts @@ -77,6 +77,21 @@ export class WalletsService { return transaction; } + + //*********************************** */ + + async createSubscriptionTransaction(amount: Decimal, walletId: string, queryRunner: QueryRunner) { + const transaction = queryRunner.manager.create(WalletTransaction, { + amount, + wallet: { id: walletId }, + type: TransactionType.DEBIT, + description: WalletMessage.SUBSCRIPTION_WALLET_TRANSFER, + }); + + await queryRunner.manager.save(WalletTransaction, transaction); + + return transaction; + } //*********************************** */ async getTransaction(userId: string, paginationDto: PaginationDto) { @@ -161,4 +176,12 @@ export class WalletsService { return { transactions, count, pagination: true }; } + + //*********************************** */ + + async getWalletByUserId(userId: string, queryRunner: QueryRunner) { + const wallet = await queryRunner.manager.findOneBy(Wallet, { user: { id: userId } }); + if (!wallet) throw new BadRequestException(WalletMessage.WALLET_NOT_FOUND); + return wallet; + } }