From c306dca107b0472be19ac44b5882330ba32d5a02 Mon Sep 17 00:00:00 2001 From: Matin Date: Wed, 19 Feb 2025 16:31:23 +0330 Subject: [PATCH] chore: delete permission for get ticket categories --- src/common/enums/message.enum.ts | 2 + .../announcements/announcement.service.ts | 4 +- .../discounts/entities/discount.entity.ts | 10 +- .../entities/usage-discount.entity.ts | 18 +++ .../repositories/usage-discount.repository.ts | 12 ++ .../DTO/apply-discount-invoice.dto.ts | 6 +- src/modules/invoices/invoices.controller.ts | 36 ++++- src/modules/invoices/invoices.module.ts | 8 +- .../invoices/providers/invoices.service.ts | 143 +++++++++++++++++- src/modules/users/entities/user.entity.ts | 4 + .../wallets/providers/wallets.service.ts | 15 ++ 11 files changed, 239 insertions(+), 19 deletions(-) create mode 100644 src/modules/discounts/entities/usage-discount.entity.ts create mode 100644 src/modules/discounts/repositories/usage-discount.repository.ts diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 3f54f21..4e3f5b4 100644 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -353,6 +353,7 @@ export const enum InvoiceMessage { INVOICE_IS_OVERDUE = "فاکتور منقضی شده است", INVOICE_PAID = "صورت حساب با موفقیت پرداخت شد", DISCOUNT_APPLIED = "تخفیف با موفقیت اعمال شد", + DISCOUNT_CANCELLED = "تخفیف با موفقیت لغو شد", } export const enum LearningMessage { @@ -400,6 +401,7 @@ export const enum DiscountMessage { DISCOUNT_CODE_STRING = "کد تخفیف باید یک رشته باشد", DISCOUNT_NOT_FOUND = "کد تخفیف یافت نشد یا غیرفعال است", DISCOUNT_NOT_APPLICABLE = "این کد تخفیف برای هیچ یک از پلن‌های اشتراک در فاکتور قابل اعمال نیست", + ALREADY_USED = "این کد تخفیف قبلا استفاده شده است", } export const enum EmailMessage { diff --git a/src/modules/announcements/announcement.service.ts b/src/modules/announcements/announcement.service.ts index 6b80dc5..e08d2ce 100644 --- a/src/modules/announcements/announcement.service.ts +++ b/src/modules/announcements/announcement.service.ts @@ -100,7 +100,7 @@ export class AnnouncementService { const [, count] = await queryBuilder.skip(skip).take(limit).getManyAndCount(); - const transformedResults = rawResults.map((raw) => ({ + const announcements = rawResults.map((raw) => ({ id: raw.announcement_id, createdAt: raw.announcement_createdAt, updatedAt: raw.announcement_updatedAt, @@ -117,7 +117,7 @@ export class AnnouncementService { isRead: raw.userAnnouncements_isRead ? true : false, })); - return { transformedResults, count }; + return { announcements, count, paginate: true }; } async getOneAnnouncement(id: string, userId: string) { diff --git a/src/modules/discounts/entities/discount.entity.ts b/src/modules/discounts/entities/discount.entity.ts index 19f38d0..72f4aaa 100644 --- a/src/modules/discounts/entities/discount.entity.ts +++ b/src/modules/discounts/entities/discount.entity.ts @@ -1,9 +1,11 @@ // eslint-disable-next-line import/no-named-as-default import Decimal from "decimal.js"; -import { Column, Entity, JoinTable, ManyToMany } from "typeorm"; +import { Column, Entity, JoinTable, ManyToMany, OneToMany } from "typeorm"; +import { UsageDiscount } from "./usage-discount.entity"; import { BaseEntity } from "../../../common/entities/base.entity"; import { DecimalTransformer } from "../../../common/transformers/decimal.transformer"; +import { Invoice } from "../../invoices/entities/invoice.entity"; import { SubscriptionPlan } from "../../subscriptions/entities/subscription.entity"; import { User } from "../../users/entities/user.entity"; import { DiscountCalculationType, DiscountType } from "../enums/discount-type.enum"; @@ -41,4 +43,10 @@ export class Discount extends BaseEntity { @ManyToMany(() => User, (user) => user.discounts, { nullable: true }) @JoinTable() users: User[]; + + @OneToMany(() => Invoice, (invoice) => invoice.discount) + invoice: Invoice; + + @OneToMany(() => UsageDiscount, (usageDiscount) => usageDiscount.discount) + usages: UsageDiscount[]; } diff --git a/src/modules/discounts/entities/usage-discount.entity.ts b/src/modules/discounts/entities/usage-discount.entity.ts new file mode 100644 index 0000000..d48fc73 --- /dev/null +++ b/src/modules/discounts/entities/usage-discount.entity.ts @@ -0,0 +1,18 @@ +import { Column, Entity, JoinTable, ManyToMany, ManyToOne } from "typeorm"; + +import { Discount } from "./discount.entity"; +import { BaseEntity } from "../../../common/entities/base.entity"; +import { User } from "../../users/entities/user.entity"; + +@Entity() +export class UsageDiscount extends BaseEntity { + @Column({ type: "timestamptz" }) + usageDate: Date; + + @ManyToOne(() => Discount, (discount) => discount.usages) + discount: Discount; + + @ManyToMany(() => User, (user) => user.usageDiscounts) + @JoinTable() + users: User[]; +} diff --git a/src/modules/discounts/repositories/usage-discount.repository.ts b/src/modules/discounts/repositories/usage-discount.repository.ts new file mode 100644 index 0000000..cbfba96 --- /dev/null +++ b/src/modules/discounts/repositories/usage-discount.repository.ts @@ -0,0 +1,12 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { UsageDiscount } from "../entities/usage-discount.entity"; + +@Injectable() +export class UsageDiscountRepository extends Repository { + constructor(@InjectRepository(UsageDiscount) usageDiscountRepository: Repository) { + super(usageDiscountRepository.target, usageDiscountRepository.manager, usageDiscountRepository.queryRunner); + } +} diff --git a/src/modules/invoices/DTO/apply-discount-invoice.dto.ts b/src/modules/invoices/DTO/apply-discount-invoice.dto.ts index e279eda..3d13f92 100644 --- a/src/modules/invoices/DTO/apply-discount-invoice.dto.ts +++ b/src/modules/invoices/DTO/apply-discount-invoice.dto.ts @@ -1,12 +1,10 @@ +import { ApiProperty } from "@nestjs/swagger"; import { IsNotEmpty, IsString } from "class-validator"; import { DiscountMessage } from "../../../common/enums/message.enum"; export class ApplyDiscountDto { - @IsString({ message: DiscountMessage.INVOICE_ID_STRING }) - @IsNotEmpty({ message: DiscountMessage.INVOICE_ID_REQUIRED }) - invoiceId: string; - + @ApiProperty({ description: "Discount code" }) @IsString({ message: DiscountMessage.DISCOUNT_CODE_STRING }) @IsNotEmpty({ message: DiscountMessage.DISCOUNT_CODE_REQUIRED }) discountCode: string; diff --git a/src/modules/invoices/invoices.controller.ts b/src/modules/invoices/invoices.controller.ts index d21f3b4..5befee5 100644 --- a/src/modules/invoices/invoices.controller.ts +++ b/src/modules/invoices/invoices.controller.ts @@ -16,6 +16,8 @@ import { PermissionEnum } from "../users/enums/permission.enum"; export class InvoicesController { constructor(private readonly invoiceService: InvoicesService) {} + /**************************************** */ + @ApiOperation({ summary: "create an invoice ==> admin route" }) @PermissionsDec(PermissionEnum.INVOICES) @AuthGuards() @@ -24,6 +26,8 @@ export class InvoicesController { return this.invoiceService.createInvoiceAdmin(createDto); } + /**************************************** */ + @ApiOperation({ summary: "get all invoices ==> admin route" }) @AuthGuards() @PermissionsDec(PermissionEnum.INVOICES) @@ -33,6 +37,8 @@ export class InvoicesController { return this.invoiceService.getInvoices(queryDto); } + /**************************************** */ + @ApiOperation({ summary: "get all user invoices" }) @AuthGuards() @Pagination() @@ -41,6 +47,8 @@ export class InvoicesController { return this.invoiceService.getUserInvoices(queryDto, userId); } + /**************************************** */ + @ApiOperation({ summary: "get single invoice by Id " }) @AuthGuards() @Get(":id") @@ -48,6 +56,8 @@ export class InvoicesController { return this.invoiceService.getInvoiceById(paramDto.id, isAdmin, userId); } + /**************************************** */ + @ApiOperation({ summary: "approve invoice by user" }) @AuthGuards() @Patch(":id/approve") @@ -55,8 +65,30 @@ export class InvoicesController { return this.invoiceService.approveInvoiceByUser(paramDto.id, userId); } + /**************************************** */ + + @ApiOperation({ summary: "apply discount on invoice by user" }) + @AuthGuards() @Post(":id/apply-discount") - async applyDiscount(@Param() paramDto: ParamDto, @Body() applyDiscountDto: ApplyDiscountDto) { - return this.invoiceService.applyDiscount(paramDto.id, applyDiscountDto); + async applyDiscount(@Param() paramDto: ParamDto, @Body() applyDiscountDto: ApplyDiscountDto, @UserDec("id") userId: string) { + return this.invoiceService.applyDiscount(paramDto.id, applyDiscountDto, userId); + } + + /**************************************** */ + + @ApiOperation({ summary: "cancel discount by user" }) + @AuthGuards() + @Post(":id/cancel-discount") + cancelDiscount(@Param() paramDto: ParamDto, @UserDec("id") userId: string) { + return this.invoiceService.cancelDiscount(paramDto.id, userId); + } + + /**************************************** */ + + @ApiOperation({ summary: "pay invoice by user" }) + @AuthGuards() + @Post(":id/pay") + async payInvoice(@Param() paramDto: ParamDto, @UserDec("id") userId: string) { + return this.invoiceService.payInvoice(paramDto.id, userId); } } diff --git a/src/modules/invoices/invoices.module.ts b/src/modules/invoices/invoices.module.ts index d7c7bef..9e8f3fb 100644 --- a/src/modules/invoices/invoices.module.ts +++ b/src/modules/invoices/invoices.module.ts @@ -7,11 +7,15 @@ import { InvoicesController } from "./invoices.controller"; import { InvoicesService } from "./providers/invoices.service"; import { InvoicesRepository } from "./repositories/invoices.repository"; import { Discount } from "../discounts/entities/discount.entity"; +import { UsageDiscount } from "../discounts/entities/usage-discount.entity"; import { DiscountRepository } from "../discounts/repositories/discount.repository"; +import { UsageDiscountRepository } from "../discounts/repositories/usage-discount.repository"; +import { UsersModule } from "../users/users.module"; +import { WalletsModule } from "../wallets/wallets.module"; @Module({ - imports: [TypeOrmModule.forFeature([Invoice, InvoiceItem, Discount])], - providers: [InvoicesService, InvoicesRepository, DiscountRepository], + imports: [TypeOrmModule.forFeature([Invoice, InvoiceItem, Discount, UsageDiscount]), UsersModule, WalletsModule], + providers: [InvoicesService, InvoicesRepository, DiscountRepository, UsageDiscountRepository], controllers: [InvoicesController], exports: [InvoicesService], }) diff --git a/src/modules/invoices/providers/invoices.service.ts b/src/modules/invoices/providers/invoices.service.ts index abe78e5..1b2aef6 100644 --- a/src/modules/invoices/providers/invoices.service.ts +++ b/src/modules/invoices/providers/invoices.service.ts @@ -1,13 +1,18 @@ import { BadRequestException, Injectable } from "@nestjs/common"; // eslint-disable-next-line import/no-named-as-default import dayjs from "dayjs"; +// eslint-disable-next-line import/no-named-as-default import Decimal from "decimal.js"; -import { QueryRunner } from "typeorm"; +import { DataSource, QueryRunner } from "typeorm"; -import { DiscountMessage, InvoiceMessage } from "../../../common/enums/message.enum"; +import { DiscountMessage, InvoiceMessage, WalletMessage } from "../../../common/enums/message.enum"; import { DiscountRepository } from "../../discounts/repositories/discount.repository"; +import { UsageDiscountRepository } from "../../discounts/repositories/usage-discount.repository"; import { SubscriptionPlan } from "../../subscriptions/entities/subscription.entity"; +import { UsersService } from "../../users/providers/users.service"; import { PaginationUtils } from "../../utils/providers/pagination.utils"; +import { Wallet } from "../../wallets/entities/wallet.entity"; +import { WalletsService } from "../../wallets/providers/wallets.service"; import { ApplyDiscountDto } from "../DTO/apply-discount-invoice.dto"; import { CreateInvoiceDto } from "../DTO/create-invoice.dto"; import { InvoicesSearchQueryDto, UserInvoicesSearchQueryDto } from "../DTO/invoices-search-query.dto"; @@ -21,6 +26,10 @@ export class InvoicesService { constructor( private readonly invoiceRepository: InvoicesRepository, private readonly discountRepository: DiscountRepository, + private readonly usageDiscountRepository: UsageDiscountRepository, + private readonly usersService: UsersService, + private readonly walletsService: WalletsService, + private readonly dataSource: DataSource, // private readonly invoiceItemsRepository: InvoiceItemsRepository, ) {} @@ -198,8 +207,56 @@ export class InvoicesService { //*********************************** */ - async applyDiscount(invoiceId: string, applyDiscountDto: ApplyDiscountDto) { - const invoice = await this.invoiceRepository.findOneBy({ id: invoiceId }); + async payInvoice(invoiceId: string, userId: string) { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + try { + const user = await this.usersService.findOneByIdWithQueryRunner(userId, queryRunner); + + const invoice = await this.getPendingInvoiceByIdWithQueryRunner(invoiceId, user.id, queryRunner); + + const userWallet = await this.walletsService.getWalletByUserId(user.id, queryRunner); + + if (new Decimal(userWallet.balance).lessThan(invoice.totalPrice)) throw new BadRequestException(WalletMessage.INSUFFICIENT_BALANCE); + + userWallet.balance = new Decimal(userWallet.balance).sub(invoice.totalPrice); + + await queryRunner.manager.save(Wallet, userWallet); + + await this.walletsService.createInvoiceTransaction(invoice.totalPrice, userWallet.id, queryRunner); + + invoice.status = InvoiceStatus.PAID; + invoice.paidAt = new Date(); + + await queryRunner.manager.save(Invoice, invoice); + console.log(invoice); + await queryRunner.commitTransaction(); + return { + message: InvoiceMessage.INVOICE_PAID, + invoice, + }; + } catch (error) { + await queryRunner.rollbackTransaction(); + throw error; + } finally { + await queryRunner.release(); + } + } + + //*********************************** */ + + async getPendingInvoiceByIdWithQueryRunner(invoiceId: string, userId: string, queryRunner: QueryRunner) { + const invoice = await queryRunner.manager.findOneBy(Invoice, { user: { id: userId }, status: InvoiceStatus.PENDING, id: invoiceId }); + console.log(invoice, invoiceId, userId); + if (!invoice) throw new BadRequestException(InvoiceMessage.NOT_FOUND_BY_ID); + return invoice; + } + + //*********************************** */ + + async applyDiscount(invoiceId: string, applyDiscountDto: ApplyDiscountDto, userId: string) { + const invoice = await this.invoiceRepository.findOneBy({ id: invoiceId, status: InvoiceStatus.PENDING }); if (!invoice) throw new BadRequestException(InvoiceMessage.NOT_FOUND_BY_ID); const discount = await this.discountRepository.findOne({ @@ -210,6 +267,9 @@ export class InvoicesService { throw new BadRequestException(DiscountMessage.NOT_FOUND); } + const hasUsedDiscount = await this.hasUserUsedDiscount(userId, discount.id); + if (hasUsedDiscount) throw new BadRequestException(DiscountMessage.ALREADY_USED); + if (discount.subscriptionPlans && discount.subscriptionPlans.length > 0) { const isApplicable = invoice.items.some((item) => { const subscriptionPlan = item.subscriptionPlan; @@ -222,15 +282,82 @@ export class InvoicesService { } if (discount.users && discount.users.length > 0) { - if (!invoice.user || !discount.users.some((user) => user.id === invoice.user.id)) { - throw new BadRequestException("This discount is not applicable for this user."); + if (!discount.users.some((user) => user.id === userId)) { + throw new BadRequestException(DiscountMessage.CAN_NOT_APPLICABLE); } } - invoice.discount = discount; + const discountAmount = + discount.calculationType === "PERCENTAGE" + ? new Decimal(invoice.totalPrice).mul(discount.amount).div(100) + : new Decimal(discount.amount); + + invoice.totalPrice = new Decimal(invoice.totalPrice).minus(discountAmount); await this.invoiceRepository.save(invoice); - return { invoice }; + const usageDiscount = this.usageDiscountRepository.create({ + discount, + users: [{ id: userId }], + usageDate: new Date(), + }); + + await this.usageDiscountRepository.save(usageDiscount); + + return { + message: InvoiceMessage.DISCOUNT_APPLIED, + invoice, + }; + } + + async cancelDiscount(invoiceId: string, userId: string) { + const invoice = await this.invoiceRepository.findOneBy({ id: invoiceId, status: InvoiceStatus.PENDING }); + if (!invoice) throw new BadRequestException(InvoiceMessage.NOT_FOUND_BY_ID); + + const usage = await this.usageDiscountRepository.findOne({ + where: { + users: { + id: userId, + }, + }, + relations: { + discount: true, + }, + }); + + console.log(usage); + if (!usage) throw new BadRequestException(DiscountMessage.NOT_FOUND); + + const discount = await this.discountRepository.findOneBy({ id: usage.discount.id }); + if (!discount) throw new BadRequestException(DiscountMessage.NOT_FOUND); + + const discountAmount = + discount.calculationType === "PERCENTAGE" + ? new Decimal(invoice.totalPrice).mul(discount.amount).div(100) + : new Decimal(discount.amount); + + invoice.totalPrice = new Decimal(invoice.totalPrice).add(discountAmount); + await this.invoiceRepository.save(invoice); + + await this.usageDiscountRepository.delete(usage.id); + + return { + message: InvoiceMessage.DISCOUNT_CANCELLED, + invoice, + }; + } + + private async hasUserUsedDiscount(userId: string, discountId: string): Promise { + const usage = await this.usageDiscountRepository.findOne({ + where: { + users: { + id: userId, + }, + discount: { + id: discountId, + }, + }, + }); + return !!usage; } // async calculateFinalPrice(invoiceId: string): Promise { diff --git a/src/modules/users/entities/user.entity.ts b/src/modules/users/entities/user.entity.ts index 057fb68..2c58e62 100644 --- a/src/modules/users/entities/user.entity.ts +++ b/src/modules/users/entities/user.entity.ts @@ -11,6 +11,7 @@ import { UserAnnouncement } from "../../announcements/entities/user-announcement import { Criticism } from "../../criticisms/entities/criticism.entity"; import { DanakServiceReview } from "../../danak-services/entities/danak-service-review.entity"; import { Discount } from "../../discounts/entities/discount.entity"; +import { UsageDiscount } from "../../discounts/entities/usage-discount.entity"; import { Invoice } from "../../invoices/entities/invoice.entity"; import { LearningProgress } from "../../learnings/entities/learning-progress.entity"; import { Notification } from "../../notifications/entities/notification.entity"; @@ -114,6 +115,9 @@ export class User extends BaseEntity { @OneToOne(() => Address, (address) => address.user, { cascade: true, nullable: true }) @JoinColumn() address: Address; + + @ManyToMany(() => UsageDiscount, (usageDiscount) => usageDiscount.users) + usageDiscounts: UsageDiscount[]; } // @ManyToMany(() => DanakService, (danakService) => danakService.users) diff --git a/src/modules/wallets/providers/wallets.service.ts b/src/modules/wallets/providers/wallets.service.ts index 293a7c1..13d3ae9 100644 --- a/src/modules/wallets/providers/wallets.service.ts +++ b/src/modules/wallets/providers/wallets.service.ts @@ -184,4 +184,19 @@ export class WalletsService { if (!wallet) throw new BadRequestException(WalletMessage.WALLET_NOT_FOUND); return wallet; } + + //*********************************** */ + + async createInvoiceTransaction(amount: Decimal, walletId: string, queryRunner: QueryRunner) { + const transaction = queryRunner.manager.create(WalletTransaction, { + amount, + wallet: { id: walletId }, + type: TransactionType.DEBIT, + description: WalletMessage.INVOICE_WALLET_TRANSFER, + }); + + await queryRunner.manager.save(WalletTransaction, transaction); + + return transaction; + } }