This commit is contained in:
mahyargdz
2025-09-14 15:15:03 +03:30
commit be82059172
534 changed files with 51310 additions and 0 deletions
@@ -0,0 +1,84 @@
import { Expose } from "class-transformer";
import { IsArray, IsBoolean, IsMongoId, IsNotEmpty, IsNumber, IsOptional, IsString } from "class-validator";
import { ApiProperty } from "../../../common/decorator/swggerDocs";
import { IsValidPersianDate } from "../../../common/decorator/validation.decorator";
export class CreateCouponDTO {
@Expose()
@IsOptional()
@IsNotEmpty()
@IsNumber()
@ApiProperty({ type: "number", example: 10, description: "Discount percentage for the coupon" })
discountPercentage?: number;
@Expose()
@IsOptional()
@IsNotEmpty()
@IsNumber()
@ApiProperty({ type: "number", example: 10000, description: "Flat discount amount" })
discountAmount?: number;
@Expose()
@IsOptional()
@IsNotEmpty()
@IsNumber()
@ApiProperty({ type: "number", example: 5, description: "Maximum number of uses for the coupon" })
usageLimit: number;
@Expose()
@IsOptional()
@IsNotEmpty()
@IsNumber()
@ApiProperty({ type: "number", example: 5, description: "Maximum number of uses for the coupon per user" })
userUsageLimit: number;
@Expose()
@IsOptional()
@IsNotEmpty()
@IsNumber()
@ApiProperty({ type: "number", example: 100000, description: "Minimum purchase amount to use the coupon in toman" })
minPurchaseAmount: number;
@Expose()
@IsOptional()
@IsNotEmpty()
@IsArray()
@IsMongoId({ each: true })
@ApiProperty({ type: "Array", example: ["60d21b4967d0d8992e610c85"], description: "categories for the coupon" })
category?: string[];
@Expose()
@IsOptional()
@IsNotEmpty()
@IsArray()
@ApiProperty({ type: "Array", example: [100001, 100102], description: "List of included product IDs" })
includedProducts?: number[];
@Expose()
@IsOptional()
@IsNotEmpty()
@IsArray()
@ApiProperty({ type: "Array", example: [100001, 100102], description: "List of excluded product IDs" })
excludedProducts?: number[];
@Expose()
@IsOptional()
@IsNotEmpty()
@IsString()
@ApiProperty({ type: "string", example: "کد تخفیف", description: "Description of the coupon" })
description: string;
@Expose()
@IsOptional()
@IsNotEmpty()
@IsBoolean()
@ApiProperty({ type: "boolean", example: false, description: "Whether the coupon applies to special sales" })
includeSpecialSale: boolean;
@Expose()
@IsNotEmpty()
@IsValidPersianDate()
@ApiProperty({ type: "string", example: "1401/06/05", description: "Coupon expiration date in jalali format" })
expirationDate: string;
}
@@ -0,0 +1,63 @@
import { Expose } from "class-transformer";
import { IsArray, IsMongoId, IsNotEmpty, IsNumber, IsOptional } from "class-validator";
import { ApiProperty } from "../../../common/decorator/swggerDocs";
export class UpdateCouponDTO {
@Expose()
@IsOptional()
@IsNotEmpty()
@IsNumber()
@ApiProperty({ type: "number", example: 10, description: "Discount percentage for the coupon" })
discountPercentage?: number;
@Expose()
@IsOptional()
@IsNotEmpty()
@IsNumber()
@ApiProperty({ type: "number", example: 10000, description: "Flat discount amount" })
discountAmount?: number;
@Expose()
@IsOptional()
@IsNotEmpty()
@IsNumber()
@ApiProperty({ type: "number", example: 5, description: "Maximum number of uses for the coupon" })
usageLimit?: number;
@Expose()
@IsOptional()
@IsNotEmpty()
@IsNumber()
@ApiProperty({ type: "number", example: 5, description: "Maximum number of uses for the coupon per user" })
userUsageLimit?: number;
@Expose()
@IsOptional()
@IsNotEmpty()
@IsNumber()
@ApiProperty({ type: "number", example: 100000, description: "Minimum purchase amount to use the coupon in toman" })
minPurchaseAmount?: number;
@Expose()
@IsOptional()
@IsNotEmpty()
@IsArray()
@IsMongoId({ each: true })
@ApiProperty({ type: "Array", example: ["60d21b4967d0d8992e610c85"], description: "categories for the coupon" })
category?: string[];
@Expose()
@IsOptional()
@IsNotEmpty()
@IsArray()
@ApiProperty({ type: "Array", example: [100001, 100102], description: "List of included product IDs" })
includedProducts?: number[];
@Expose()
@IsOptional()
@IsNotEmpty()
@IsArray()
@ApiProperty({ type: "Array", example: [100001, 100102], description: "List of excluded product IDs" })
excludedProducts?: number[];
}
@@ -0,0 +1,20 @@
import { Expose } from "class-transformer";
import { IsNotEmpty, IsString } from "class-validator";
import { ApiProperty } from "../../../common/decorator/swggerDocs";
import { IsValidId } from "../../../common/decorator/validation.decorator";
import { CartShipmentItemModel } from "../../cart/models/cartShipmentItem.model";
export class ValidateCouponDTO {
@Expose()
@IsNotEmpty()
@IsString()
@ApiProperty({ type: "string", example: "DISCOUNT2024" })
code: string;
@Expose()
@IsNotEmpty()
@IsValidId(CartShipmentItemModel)
@ApiProperty({ type: "string", example: "5f7b1b1b1b1b1b1b1b1b1b1b1", description: "cartShipment item id" })
cartShipmentItemId: string;
}
+96
View File
@@ -0,0 +1,96 @@
import { Request } from "express";
import rateLimit from "express-rate-limit";
import { inject } from "inversify";
import { controller, httpGet, httpPatch, httpPost, request, requestBody, requestParam } from "inversify-express-utils";
import { CouponService } from "./coupon.service";
import { HttpStatus } from "../../common";
import { CreateCouponDTO } from "./DTO/createCoupon.dto";
import { UpdateCouponDTO } from "./DTO/updateCoupon.dto";
import { ValidateCouponDTO } from "./DTO/validateCoupon.dto";
import { BaseController } from "../../common/base/controller";
import { ApiAuth, ApiModel, ApiOperation, ApiParam, ApiResponse, ApiTags } from "../../common/decorator/swggerDocs";
import { appConfig } from "../../core/config/app.config";
import { Guard } from "../../core/middlewares/guard.middleware";
import { ValidationMiddleware } from "../../core/middlewares/validator.middleware";
import { IOCTYPES } from "../../IOC/ioc.types";
import { ISeller } from "../seller/models/Abstraction/ISeller";
import { OwnerRef } from "../shop/models/Abstraction/IShop";
import { IUser } from "../user/models/Abstraction/IUser";
@controller("/coupon")
@ApiTags("Coupon")
class CouponController extends BaseController {
@inject(IOCTYPES.CouponService) couponService: CouponService;
@ApiOperation("get all seller created coupon ==> login as seller")
@ApiResponse("successful")
@ApiAuth()
@httpGet("", Guard.authSeller())
public async getSellerCoupons(@request() req: Request) {
const seller = req.user as ISeller;
const data = await this.couponService.getSellerCoupons(seller._id.toString(), OwnerRef.SELLER);
return this.response(data);
}
@ApiOperation("get a single coupon")
@ApiParam("id", "coupon id", true)
@ApiAuth()
@httpGet("/:id", Guard.authSeller())
public async getSingleCoupon(@requestParam("id") couponId: string) {
const data = await this.couponService.getSingleCoupon(couponId);
return this.response(data);
}
@ApiOperation("create a Coupon ==> login as seller")
@ApiResponse("successful", HttpStatus.Created)
@ApiModel(CreateCouponDTO)
@ApiAuth()
@httpPost("", rateLimit(appConfig.rate), Guard.authSeller(), ValidationMiddleware.validateInput(CreateCouponDTO))
public async createCoupon(@requestBody() createDto: CreateCouponDTO, @request() req: Request) {
const seller = req.user as ISeller;
const data = await this.couponService.createCoupon(createDto, seller._id.toString(), OwnerRef.SELLER);
return this.response(data, HttpStatus.Created);
}
@ApiOperation("deactivate a coupon ==> login as seller")
@ApiResponse("successful")
@ApiAuth()
@ApiParam("id", "coupon id", true)
@httpPatch("/:id/status", Guard.authSeller())
public async changeCouponStatus(@request() req: Request, @requestParam("id") couponId: string) {
const seller = req.user as ISeller;
const data = await this.couponService.changeCouponStatus(seller._id.toString(), couponId, OwnerRef.SELLER);
return this.response(data);
}
@ApiOperation("update a seller coupon ==> login as seller")
@ApiResponse("successful")
@ApiModel(UpdateCouponDTO)
@ApiParam("id", "coupon id", true)
@ApiAuth()
@httpPatch("/:id", Guard.authSeller(), ValidationMiddleware.validateInput(UpdateCouponDTO))
public async updateSellerCoupon(
@requestBody() updateDto: UpdateCouponDTO,
@request() req: Request,
@requestParam("id") couponId: string,
) {
const seller = req.user as ISeller;
const data = await this.couponService.updateCoupon(updateDto, seller._id.toString(), couponId, OwnerRef.SELLER);
return this.response(data);
}
@ApiOperation("add a coupon to user cart ===> login as user")
@ApiResponse("successful")
@ApiResponse("bad request", HttpStatus.BadRequest)
@ApiModel(ValidateCouponDTO)
@ApiAuth()
@httpPost("/add-coupon", Guard.authUser(), ValidationMiddleware.validateInput(ValidateCouponDTO))
public async addCoupon(@requestBody() validateDto: ValidateCouponDTO, @request() req: Request) {
const user = req.user as IUser;
const data = await this.couponService.applyCouponDiscount(validateDto, user._id.toString());
return this.response(data);
}
}
export { CouponController };
+24
View File
@@ -0,0 +1,24 @@
import { ICoupon } from "./models/Abstraction/ICoupon";
import { ICouponUsage } from "./models/Abstraction/ICouponUsage";
import { CouponModel } from "./models/coupon.model";
import { CouponUsageModel } from "./models/couponUsage.model";
import { BaseRepository } from "../../common/base/repository";
export class CouponRepo extends BaseRepository<ICoupon> {
constructor() {
super(CouponModel);
}
}
export function createCouponRepo(): CouponRepo {
return new CouponRepo();
}
export class CouponUsageRepo extends BaseRepository<ICouponUsage> {
constructor() {
super(CouponUsageModel);
}
}
export function createCouponUsageRepo(): CouponUsageRepo {
return new CouponUsageRepo();
}
+287
View File
@@ -0,0 +1,287 @@
import { randomBytes } from "crypto";
import { inject, injectable } from "inversify";
import { ClientSession, isValidObjectId, startSession } from "mongoose";
import { CouponRepo, CouponUsageRepo } from "./coupon.repository";
import { CreateCouponDTO } from "./DTO/createCoupon.dto";
import { UpdateCouponDTO } from "./DTO/updateCoupon.dto";
import { ValidateCouponDTO } from "./DTO/validateCoupon.dto";
import { CartMessage, CommonMessage, CouponMessage, ShopMessage } from "../../common/enums/message.enum";
import { BadRequestError } from "../../core/app/app.errors";
import { IOCTYPES } from "../../IOC/ioc.types";
import { TimeService } from "../../utils/time.service";
import { CartRepository, CartShipItemRepo } from "../cart/cart.repository";
import { CartService } from "../cart/cart.service";
import { ICartShipmentItem } from "../cart/models/Abstraction/ICartShipmentItem";
import { PaymentService } from "../payment/providers/payment.service";
import { OwnerRef } from "../shop/models/Abstraction/IShop";
import { ShopRepo } from "../shop/shop.repository";
@injectable()
class CouponService {
@inject(IOCTYPES.CouponRepo) couponRepo: CouponRepo;
@inject(IOCTYPES.CouponUsageRepo) couponUsageRepo: CouponUsageRepo;
@inject(IOCTYPES.PaymentService) paymentService: PaymentService;
@inject(IOCTYPES.CartRepository) cartRepo: CartRepository;
@inject(IOCTYPES.CartService) cartService: CartService;
@inject(IOCTYPES.ShopRepo) shopRepo: ShopRepo;
@inject(IOCTYPES.CartShipItemRepo) cartShipItemRepo: CartShipItemRepo;
async getSellersCoupons() {
const coupons = await this.couponRepo.model.aggregate([
{
$match: {
deleted: false,
},
},
{
$lookup: {
from: "shops",
localField: "shopId",
foreignField: "_id",
as: "shop",
},
},
{
$unwind: "$shop",
},
{
$match: {
"shop.ownerRef": OwnerRef.SELLER,
"shop.deleted": false,
},
},
{
$project: {
shop: 0,
},
},
]);
return { coupons };
}
async getSellerCoupons(ownerId: string, ownerRef: OwnerRef) {
const shop = await this.shopRepo.model.findOne({ owner: ownerId, ownerRef });
if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound);
return {
coupons: await this.couponRepo.model.find({ shopId: shop._id, deleted: false }),
};
}
async getSingleCoupon(couponId: string) {
if (!isValidObjectId(couponId)) throw new BadRequestError(CouponMessage.InvalidId);
const coupon = await this.couponRepo.model.findById(couponId);
if (!coupon) throw new BadRequestError(CouponMessage.InvalidId);
return { coupon };
}
//##############################
async createCoupon(createDto: CreateCouponDTO, ownerId: string, ownerRef: OwnerRef) {
const { discountAmount, discountPercentage, includedProducts, excludedProducts } = createDto;
if (!discountAmount && !discountPercentage) throw new BadRequestError(CouponMessage.DiscountAmountOrPercentageRequired);
if (discountAmount && discountPercentage) throw new BadRequestError(CouponMessage.BothDiscountOrPercentageShouldNotSend);
if (includedProducts && excludedProducts) throw new BadRequestError(CouponMessage.BothIncludedAndExcludedProductsShouldNotSend);
const shop = await this.shopRepo.model.findOne({ owner: ownerId, ownerRef });
if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound);
const code = this.generateCouponCode();
const coupon = await this.couponRepo.model.create({
shopId: shop._id,
code,
...createDto,
});
return { coupon };
}
//##############################
async updateCoupon(updateDto: UpdateCouponDTO, ownerId: string, couponId: string, ownerRef: OwnerRef) {
if (!isValidObjectId(couponId)) throw new BadRequestError(CouponMessage.InvalidId);
const shop = await this.shopRepo.model.findOne({ owner: ownerId, ownerRef });
if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound);
const { discountAmount, discountPercentage, includedProducts, excludedProducts } = updateDto;
if (!discountAmount && !discountPercentage) throw new BadRequestError(CouponMessage.DiscountAmountOrPercentageRequired);
if (discountAmount && discountPercentage) throw new BadRequestError(CouponMessage.BothDiscountOrPercentageShouldNotSend);
if (includedProducts && excludedProducts) throw new BadRequestError(CouponMessage.BothIncludedAndExcludedProductsShouldNotSend);
const updatedCoupon = await this.couponRepo.model.findOneAndUpdate(
{ _id: couponId, shopId: shop._id },
{ ...updateDto },
{ new: true },
);
if (!updatedCoupon) throw new BadRequestError(CouponMessage.InvalidId);
return {
message: CommonMessage.Updated,
coupon: updatedCoupon,
};
}
//##############################
async changeCouponStatus(ownerId: string, couponId: string, ownerRef: OwnerRef) {
if (!isValidObjectId(couponId)) throw new BadRequestError(CouponMessage.InvalidId);
const shop = await this.shopRepo.model.findOne({ owner: ownerId, ownerRef });
if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound);
const coupon = await this.couponRepo.model.findOne({ _id: couponId, shopId: shop._id });
if (!coupon) throw new BadRequestError(CouponMessage.InvalidId);
const updatedCoupon = await this.couponRepo.model.findOneAndUpdate(
{ _id: couponId, shopId: shop._id },
{ isActive: !coupon.isActive },
{ new: true },
);
return {
message: CommonMessage.Updated,
coupon: updatedCoupon,
};
}
//##############################
async applyCouponDiscount(validateDto: ValidateCouponDTO, userId: string) {
const session = await startSession();
session.startTransaction();
try {
const { price, cartShipmentItems } = await this.paymentService.getPaymentInfoS(userId);
const userCart = await this.cartRepo.model.findOne({ user: userId }).session(session);
if (!userCart) throw new BadRequestError(CartMessage.CartNotFound);
// if (cart.coupon_discount) throw new BadRequestError(CouponMessage.CouponAlreadyInCart);
const cartShipmentItem = cartShipmentItems.find((item) => item._id.toString() === validateDto.cartShipmentItemId);
if (!cartShipmentItem) throw new BadRequestError(CartMessage.InvalidCartShipmentItem);
const validCartShipmentItem = await this.cartShipItemRepo.model.findById(cartShipmentItem._id).session(session);
if (!validCartShipmentItem) throw new BadRequestError(CartMessage.InvalidCartShipmentItem);
if (cartShipmentItem.totalCouponDiscount) throw new BadRequestError(CouponMessage.CouponAlreadyInCart);
const { total_payable_price } = price;
const { coupon } = await this.validateCoupon(validateDto.code, cartShipmentItem.totalPaymentPrice, userId, session);
let discount = 0;
if (coupon.discountAmount) {
discount = coupon.discountAmount;
} else if (coupon.discountPercentage) {
discount = (total_payable_price * coupon.discountPercentage) / 100;
}
if (coupon.category && coupon.category.length > 0) {
await this.checkCartItemCategory(
userId,
coupon.category.map((cat) => cat._id.toString()),
);
}
if (coupon.includedProducts && coupon.includedProducts.length > 0) {
await this.checkCartItemIncludedProducts(cartShipmentItem, coupon.includedProducts);
}
if (coupon.excludedProducts && coupon.excludedProducts.length > 0) {
await this.checkCartItemExcludedProducts(cartShipmentItem, coupon.excludedProducts);
}
await this.couponUsageRepo.model.findOneAndUpdate(
{ user: userId, coupon: coupon._id },
{ $inc: { usageCount: 1 } },
{ new: true, upsert: true, session },
);
cartShipmentItem.totalCouponDiscount = discount;
cartShipmentItem.totalPaymentPrice = cartShipmentItem.totalPaymentPrice - discount;
userCart.coupon_discount = discount;
coupon.usageCount += 1;
await validCartShipmentItem.save({ session });
await userCart.save({ session });
await coupon.save({ session });
await session.commitTransaction();
await session.endSession();
return { message: CouponMessage.CouponAdded, afterDiscount: total_payable_price - discount };
} catch (error) {
await session.abortTransaction();
await session.endSession();
throw error;
}
}
//================================> helper method
private generateCouponCode(length = 8) {
return randomBytes(length).toString("hex").slice(0, length).toUpperCase();
}
async checkCartItemExcludedProducts(shipmentItem: ICartShipmentItem, excludedProducts: number[]) {
const items = shipmentItem.shipmentItems.filter((item) => excludedProducts.includes(item.product));
console.log({ items });
if (items.length > 0) throw new BadRequestError(CouponMessage.InvalidProduct);
return true;
}
async checkCartItemIncludedProducts(shipmentItem: ICartShipmentItem, includedProducts: number[]) {
const items = shipmentItem.shipmentItems.filter((item) => includedProducts.includes(item.product));
if (!items.length) throw new BadRequestError(CouponMessage.InvalidProduct);
return true;
}
async checkCartItemCategory(userId: string, category: string[]) {
const { cart } = await this.cartService.getCartS(userId);
const items = cart.items.filter((item) => category.includes(item.product.category._id));
if (items.length === 0) throw new BadRequestError(CouponMessage.InvalidCategory);
return true;
}
async removeCouponByAdmin(couponId: string) {
const deletedCoupon = await this.couponRepo.model.findByIdAndUpdate(couponId, { deleted: true }, { new: true });
if (!deletedCoupon) throw new BadRequestError(CouponMessage.NotFound);
return { message: CommonMessage.Deleted };
}
//##############################
private async validateCoupon(couponCode: string, totalPrice: number, userId: string, session: ClientSession) {
const coupon = await this.couponRepo.model.findOne({ code: couponCode, isActive: true, deleted: false }).session(session);
if (!coupon) throw new BadRequestError(CouponMessage.NotFound);
const expired = TimeService.isPersianDateExpired(coupon.expirationDate);
if (expired) throw new BadRequestError(CouponMessage.Expired);
if (coupon.usageLimit && coupon.usageCount >= coupon.usageLimit) throw new BadRequestError(CouponMessage.UsageLimit);
const userUsage = await this.couponUsageRepo.model.findOne({ coupon: coupon._id, user: userId }).session(session);
if (userUsage && userUsage.usageCount >= coupon.userUsageLimit) throw new BadRequestError(CouponMessage.UserCouponUsageLimit);
if (coupon.minPurchaseAmount && totalPrice < coupon.minPurchaseAmount) {
throw new BadRequestError([
CouponMessage.AmountIsNotSatisfied,
`حداقل مبلغ سبد خرید برای استفاده از این کد تخفیف ${coupon.minPurchaseAmount}`,
]);
}
return { coupon };
}
}
export { CouponService };
@@ -0,0 +1,20 @@
import { Types } from "mongoose";
export interface ICoupon {
shopId: Types.ObjectId;
discountAmount: number;
discountPercentage: number;
usageLimit: number;
usageCount: number;
userUsageLimit: number;
code: string;
minPurchaseAmount: number;
category: Types.ObjectId[];
includedProducts: number[];
excludedProducts: number[];
description: string;
isActive: boolean;
includeSpecialSale: boolean;
expirationDate: string;
deleted: boolean;
}
@@ -0,0 +1,7 @@
import { Types } from "mongoose";
export interface ICouponUsage {
coupon: Types.ObjectId;
user: Types.ObjectId;
usageCount: number;
}
+29
View File
@@ -0,0 +1,29 @@
import { Schema, model } from "mongoose";
import { ICoupon } from "./Abstraction/ICoupon";
const CouponSchema = new Schema<ICoupon>(
{
shopId: { type: Schema.Types.ObjectId, ref: "Shop", required: true },
discountPercentage: { type: Number, default: 0 },
discountAmount: { type: Number, default: 0 },
usageLimit: { type: Number, default: null },
usageCount: { type: Number, default: 0 },
userUsageLimit: { type: Number, default: null },
code: { type: String, required: true, unique: true },
minPurchaseAmount: { type: Number, default: null },
category: { type: [Schema.Types.ObjectId], ref: "Category", default: [] },
includedProducts: { type: [Number], ref: "Product", default: [] },
excludedProducts: { type: [Number], ref: "Product", default: [] },
description: { type: String, default: null },
isActive: { type: Boolean, default: true },
includeSpecialSale: { type: Boolean, default: true },
expirationDate: { type: String, required: true },
deleted: { type: Boolean, default: false },
},
{ timestamps: true, toJSON: { versionKey: false }, id: false },
);
const CouponModel = model<ICoupon>("Coupon", CouponSchema);
export { CouponModel };
@@ -0,0 +1,16 @@
import { Schema, model } from "mongoose";
import { ICouponUsage } from "./Abstraction/ICouponUsage";
const CouponUsageSchema = new Schema<ICouponUsage>(
{
coupon: { type: Schema.Types.ObjectId, ref: "Coupon", required: true },
user: { type: Schema.Types.ObjectId, ref: "User", required: true },
usageCount: { type: Number, required: true },
},
{ timestamps: true, toJSON: { versionKey: false }, id: false },
);
CouponUsageSchema.index({ coupon: 1, user: 1 }, { unique: true });
const CouponUsageModel = model<ICouponUsage>("CouponUsage", CouponUsageSchema);
export { CouponUsageModel };