diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 5fa3379..48f1f38 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -471,6 +471,11 @@ export const enum InvoiceMessage { DISCOUNT_CODE_LENGTH = "طول کد تخفیف باید بین ۱ تا ۱۰۰ کاراکتر باشد", ALREADY_HAS_DISCOUNT = "این صورت حساب قبلا دارای تخفیف است", NO_DISCOUNT = "این صورت حساب دارای تخفیف نیست", + NOT_AUTHORIZED = "شما مجاز به انجام این عملیات نیستید", + INVALID_DISCOUNT_CODE = "کد تخفیف نامعتبر است", + ORIGINAL_PRICE_NOT_FOUND = "قیمت اصلی صورت حساب یافت نشد", + NOT_FOUND_BY_ID_OR_NOT_BELONG_TO_USER = "صورت حساب یافت نشد یا متعلق به کاربر نمی باشد", + DISCOUNT_ALREADY_APPLIED = "تخفیف قبلا برای این صورت حساب اعمال شده است", } export const enum LearningMessage { diff --git a/src/modules/discounts/entities/discount.entity.ts b/src/modules/discounts/entities/discount.entity.ts index 447bd75..94a2e39 100755 --- a/src/modules/discounts/entities/discount.entity.ts +++ b/src/modules/discounts/entities/discount.entity.ts @@ -3,6 +3,7 @@ import { Column, DeleteDateColumn, Entity, 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 { DiscountApplicationType } from "../enums/discount-application-type.enum"; import { DiscountType } from "../enums/discount-type.enum"; @@ -45,4 +46,7 @@ export class Discount extends BaseEntity { @OneToMany(() => SubscriptionPlan, (subscriptionPlan) => subscriptionPlan.directDiscount) subscriptionPlans: SubscriptionPlan[]; + + @OneToMany(() => Invoice, (invoice) => invoice.discount) + invoices: Invoice[]; } diff --git a/src/modules/discounts/providers/discounts.service.ts b/src/modules/discounts/providers/discounts.service.ts index da20bb0..15a3ee4 100755 --- a/src/modules/discounts/providers/discounts.service.ts +++ b/src/modules/discounts/providers/discounts.service.ts @@ -2,9 +2,11 @@ import { randomBytes } from "node:crypto"; import { BadRequestException, Injectable } from "@nestjs/common"; import dayjs from "dayjs"; +import { Decimal } from "decimal.js"; import { DataSource, QueryRunner } from "typeorm"; import { DiscountMessage } from "../../../common/enums/message.enum"; +import { SubscriptionPlan } from "../../subscriptions/entities/subscription.entity"; import { SubscriptionsService } from "../../subscriptions/providers/subscriptions.service"; import { CreateDiscountDto } from "../DTO/create-discount.dto"; import { SearchDiscountQueryDto } from "../DTO/search-discount-query.dto"; @@ -142,6 +144,7 @@ export class DiscountsService { for (const subscriptionPlan of subscriptionPlans) { subscriptionPlan.directDiscount = discount; + await this.updatePlanPrice(subscriptionPlan, discount); await queryRunner.manager.save(subscriptionPlan); } @@ -232,9 +235,22 @@ export class DiscountsService { for (const subscriptionPlan of subscriptionPlans) { subscriptionPlan.directDiscount = discount; + await this.updatePlanPrice(subscriptionPlan, discount); await queryRunner.manager.save(subscriptionPlan); } await queryRunner.manager.save(discount); } + //************************************ */ + private async updatePlanPrice(subscriptionPlan: SubscriptionPlan, discount: Discount) { + if (!subscriptionPlan.originalPrice) subscriptionPlan.originalPrice = new Decimal(subscriptionPlan.price); + + let discountAmount: Decimal; + if (discount.type === DiscountType.PERCENTAGE) { + discountAmount = subscriptionPlan.originalPrice.mul(discount.value).div(100); + } else { + discountAmount = new Decimal(discount.value); + } + subscriptionPlan.price = subscriptionPlan.originalPrice.sub(discountAmount); + } } diff --git a/src/modules/invoices/entities/invoice.entity.ts b/src/modules/invoices/entities/invoice.entity.ts index 9979c4e..4a02794 100755 --- a/src/modules/invoices/entities/invoice.entity.ts +++ b/src/modules/invoices/entities/invoice.entity.ts @@ -20,6 +20,9 @@ export class Invoice extends BaseEntity { @Column({ type: "decimal", precision: 16, scale: 2, nullable: false, transformer: new DecimalTransformer() }) totalPrice: Decimal; + @Column({ type: "decimal", precision: 16, scale: 2, nullable: true, transformer: new DecimalTransformer() }) + originalPrice?: Decimal; + @Column({ type: "enum", enum: InvoiceStatus, default: InvoiceStatus.PENDING }) status: InvoiceStatus; @@ -40,8 +43,8 @@ export class Invoice extends BaseEntity { @OneToMany(() => InvoiceItem, (invoiceItem) => invoiceItem.invoice, { cascade: true }) items: InvoiceItem[]; - @ManyToOne(() => Discount, { nullable: true, onDelete: "SET NULL" }) - discount: Discount; + @ManyToOne(() => Discount, (discount) => discount.invoices, { nullable: true, onDelete: "RESTRICT" }) + discount?: Discount; get isOverdue(): boolean { return this.status === InvoiceStatus.PENDING && new Date() > this.dueDate; diff --git a/src/modules/invoices/invoices.controller.ts b/src/modules/invoices/invoices.controller.ts index efd053d..de83c2f 100755 --- a/src/modules/invoices/invoices.controller.ts +++ b/src/modules/invoices/invoices.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, Get, Param, Patch, Post, Query } from "@nestjs/common"; import { ApiOperation } from "@nestjs/swagger"; +import { ApplyDiscountDto } from "./DTO/apply-discount.dto"; import { CreateInvoiceDto } from "./DTO/create-invoice.dto"; import { InvoicesSearchQueryDto, UserInvoicesSearchQueryDto } from "./DTO/invoices-search-query.dto"; import { UpdateInvoiceDto } from "./DTO/update-invoice.dto"; @@ -71,15 +72,15 @@ export class InvoicesController { return this.invoiceService.payInvoice(paramDto.id, userId); } - // @ApiOperation({ summary: "apply discount on invoice by user" }) - // @Post(":id/apply-discount") - // async applyDiscount(@Param() paramDto: ParamDto, @Body() applyDiscountDto: ApplyDiscountDto, @UserDec("id") userId: string) { - // return this.discountsService.applyDiscountToInvoice(paramDto.id, applyDiscountDto.code, userId); - // } + @ApiOperation({ summary: "apply discount on invoice by user" }) + @Post(":id/apply-discount") + async applyDiscount(@Param() paramDto: ParamDto, @Body() applyDiscountDto: ApplyDiscountDto, @UserDec("id") userId: string) { + return this.invoiceService.applyDiscount(paramDto.id, applyDiscountDto.code, userId); + } - // @ApiOperation({ summary: "cancel discount by user" }) - // @Post(":id/cancel-discount") - // cancelDiscount(@Param() paramDto: ParamDto, @UserDec("id") userId: string) { - // return this.discountsService.cancelInvoiceDiscount(paramDto.id, userId); - // } + @ApiOperation({ summary: "cancel discount by user" }) + @Post(":id/cancel-discount") + cancelDiscount(@Param() paramDto: ParamDto, @UserDec("id") userId: string) { + return this.invoiceService.cancelDiscount(paramDto.id, userId); + } } diff --git a/src/modules/invoices/providers/invoices.service.ts b/src/modules/invoices/providers/invoices.service.ts index 8245540..fa8c158 100755 --- a/src/modules/invoices/providers/invoices.service.ts +++ b/src/modules/invoices/providers/invoices.service.ts @@ -9,6 +9,8 @@ import { Between, DataSource, QueryRunner } from "typeorm"; import { AuthMessage, InvoiceMessage, WalletMessage } from "../../../common/enums/message.enum"; import { VerifyOtpWithUserId } from "../../auth/DTO/verify-otp.dto"; +import { Discount } from "../../discounts/entities/discount.entity"; +import { DiscountType } from "../../discounts/enums/discount-type.enum"; import { NotificationsService } from "../../notifications/providers/notifications.service"; import { SubscriptionPlan } from "../../subscriptions/entities/subscription.entity"; import { UserSubscription } from "../../subscriptions/entities/user-subscription.entity"; @@ -267,26 +269,31 @@ export class InvoicesService { } ///********************************** */ async createInvoiceForSubscription(user: User, plan: SubscriptionPlan, userSub: UserSubscription, dueDate: Date, qryRnr: QueryRunner) { + const discount = plan.directDiscount; + const originalPrice = plan.originalPrice || plan.price; + const finalPrice = plan.price; + const invoiceItem = { name: plan.service.name, count: 1, - unitPrice: plan.price, - discount: 0, + unitPrice: originalPrice, + discount: discount ? new Decimal(originalPrice).sub(finalPrice).toNumber() : 0, subscriptionPlan: userSub, - totalPrice: plan.price, + totalPrice: finalPrice, }; - const basePrice = plan.price; - const taxAmount = new Decimal(basePrice).mul(0.1); - const totalPrice = new Decimal(basePrice).add(taxAmount); + const taxAmount = new Decimal(finalPrice).mul(0.1); + const totalPrice = new Decimal(finalPrice).add(taxAmount); const invoice = qryRnr.manager.create(Invoice, { user, totalPrice: totalPrice, + originalPrice: new Decimal(originalPrice).add(new Decimal(originalPrice).mul(0.1)), tax: taxAmount.toNumber(), status: InvoiceStatus.WAIT_PAYMENT, dueDate, items: [invoiceItem], + discount: discount || undefined, }); await qryRnr.manager.save(Invoice, invoice); @@ -345,12 +352,12 @@ export class InvoicesService { if (isAdmin) { invoice = await this.invoiceRepository.findOne({ where: { id: invoiceId }, - relations: { items: { subscriptionPlan: true }, user: true }, + relations: { items: { subscriptionPlan: true }, user: true, discount: true }, }); } else { invoice = await this.invoiceRepository.findOne({ where: { id: invoiceId, user: { id: userId } }, - relations: { items: { subscriptionPlan: true } }, + relations: { items: { subscriptionPlan: true }, discount: true }, }); } @@ -487,6 +494,7 @@ export class InvoicesService { }); return count; } + //*********************************** */ async countUserInvoices(userId: string) { @@ -558,4 +566,87 @@ export class InvoicesService { this.logger.log(`Scheduled recurring invoice for user ${createDto.userId} with interval ${createDto.recurringPeriod}`); } } + + //*********************************** */ + async applyDiscount(invoiceId: string, discountCode: string, userId: string) { + const queryRunner = this.dataSource.createQueryRunner(); + + try { + await queryRunner.connect(); + await queryRunner.startTransaction(); + const invoice = await this.getInvoiceByIdWithQueryRunner(invoiceId, userId, queryRunner); + + if (invoice.discount) throw new BadRequestException(InvoiceMessage.DISCOUNT_ALREADY_APPLIED); + + const discount = await queryRunner.manager.findOne(Discount, { + where: { code: discountCode, isActive: true }, + }); + + if (!discount) throw new BadRequestException(InvoiceMessage.INVALID_DISCOUNT_CODE); + + if (!invoice.originalPrice) invoice.originalPrice = new Decimal(invoice.totalPrice); + + let discountAmount: Decimal; + if (discount.type === DiscountType.PERCENTAGE) { + discountAmount = invoice.originalPrice.mul(discount.value).div(100); + } else { + discountAmount = new Decimal(discount.value); + } + + invoice.totalPrice = invoice.originalPrice.sub(discountAmount); + invoice.discount = discount; + + await queryRunner.manager.save(invoice); + await queryRunner.commitTransaction(); + + return { + message: InvoiceMessage.DISCOUNT_APPLIED, + invoice, + }; + } catch (error) { + await queryRunner.rollbackTransaction(); + throw error; + } finally { + await queryRunner.release(); + } + } + //*********************************** */ + async cancelDiscount(invoiceId: string, userId: string) { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const invoice = await this.getInvoiceByIdWithQueryRunner(invoiceId, userId, queryRunner); + + if (!invoice.discount) throw new BadRequestException(InvoiceMessage.NO_DISCOUNT); + + if (!invoice.originalPrice) throw new BadRequestException(InvoiceMessage.ORIGINAL_PRICE_NOT_FOUND); + + invoice.totalPrice = invoice.originalPrice; + invoice.discount = undefined; + + await queryRunner.manager.save(invoice); + await queryRunner.commitTransaction(); + + return { + message: InvoiceMessage.DISCOUNT_CANCELED, + invoice, + }; + } catch (error) { + await queryRunner.rollbackTransaction(); + throw error; + } finally { + await queryRunner.release(); + } + } + //*********************************** */ + private async getInvoiceByIdWithQueryRunner(invoiceId: string, userId: string, queryRunner: QueryRunner) { + const invoice = await queryRunner.manager.findOne(Invoice, { + where: { id: invoiceId, user: { id: userId } }, + relations: { user: true, discount: true }, + }); + if (!invoice) throw new BadRequestException(InvoiceMessage.NOT_FOUND_BY_ID_OR_NOT_BELONG_TO_USER); + return invoice; + } } diff --git a/src/modules/subscriptions/entities/subscription.entity.ts b/src/modules/subscriptions/entities/subscription.entity.ts index 0bb215d..0b5bf3a 100755 --- a/src/modules/subscriptions/entities/subscription.entity.ts +++ b/src/modules/subscriptions/entities/subscription.entity.ts @@ -20,6 +20,9 @@ export class SubscriptionPlan extends BaseEntity { @Column({ type: "decimal", precision: 16, scale: 2, nullable: false, transformer: new DecimalTransformer() }) price: Decimal; + @Column({ type: "decimal", precision: 16, scale: 2, nullable: true, transformer: new DecimalTransformer() }) + originalPrice?: Decimal; + @Column({ type: "boolean", default: true }) isActive: boolean; diff --git a/src/modules/subscriptions/providers/subscriptions.service.ts b/src/modules/subscriptions/providers/subscriptions.service.ts index 6518911..1e39656 100755 --- a/src/modules/subscriptions/providers/subscriptions.service.ts +++ b/src/modules/subscriptions/providers/subscriptions.service.ts @@ -202,6 +202,7 @@ export class SubscriptionsService { where: { id: subscribeDto.planId, service: { id: serviceId } }, relations: { service: true, + directDiscount: true, }, });