From f36125764d2e199bb3809c9f4e0655fe06b1e994 Mon Sep 17 00:00:00 2001 From: morteza-mortezai Date: Sat, 6 Dec 2025 22:05:56 +0330 Subject: [PATCH] coupon --- src/modules/cart/providers/cart.service.ts | 97 ++++++++++++++++++- src/modules/coupons/dto/create-coupon.dto.ts | 30 +++++- src/modules/coupons/entities/coupon.entity.ts | 9 ++ .../coupons/providers/coupon.service.ts | 6 ++ src/seeders/data/restaurants.data.ts | 4 +- 5 files changed, 140 insertions(+), 6 deletions(-) diff --git a/src/modules/cart/providers/cart.service.ts b/src/modules/cart/providers/cart.service.ts index 192ee04..d032a61 100644 --- a/src/modules/cart/providers/cart.service.ts +++ b/src/modules/cart/providers/cart.service.ts @@ -11,6 +11,7 @@ import { SetAddressDto } from '../dto/set-address.dto'; import { SetPaymentMethodDto } from '../dto/set-payment-method.dto'; import { SetDeliveryMethodDto } from '../dto/set-delivery-method.dto'; import { UserAddress } from '../../users/entities/user-address.entity'; +import { User } from '../../users/entities/user.entity'; import { PaymentMethod } from '../../payments/entities/payment-method.entity'; import { Delivery } from '../../delivery/entities/delivery.entity'; import { Cart, CartItem } from '../interfaces/cart.interface'; @@ -366,20 +367,35 @@ export class CartService { const orderAmount = cart.subTotal - cart.itemsDiscount; - const { valid, coupon } = await this.couponService.validateCoupon( + // check if coupon is valid and belong to the restaurant + const { valid, coupon, message } = await this.couponService.validateCoupon( applyCouponDto.code, restaurantId, Number(orderAmount), ); if (!valid) { - throw new BadRequestException('Invalid coupon code'); + throw new BadRequestException(message || 'Invalid coupon code'); } if (!coupon) { throw new BadRequestException('Coupon not found'); } + // Validate user phone if coupon is restricted to a specific user + if (coupon.userPhone) { + const user = await this.em.findOne(User, { id: userId }); + if (!user) { + throw new NotFoundException('User not found'); + } + + if (user.phone !== coupon.userPhone) { + throw new BadRequestException( + 'This coupon is only available for a specific user and cannot be applied to your account.', + ); + } + } + // Check maxUsesPerUser limit by counting how many orders this user has with this coupon if (coupon.maxUsesPerUser) { const result = await this.em.getConnection().execute<{ count: string }>( @@ -401,6 +417,40 @@ export class CartService { } } + // Validate that cart has items matching coupon criteria (if restrictions exist) + const hasCategoryRestriction = coupon.foodCategories && coupon.foodCategories.length > 0; + const hasFoodRestriction = coupon.foods && coupon.foods.length > 0; + + if (hasCategoryRestriction || hasFoodRestriction) { + // Fetch all foods in cart with their categories + const foodIds = cart.items.map(item => item.foodId); + const foods = await this.em.find(Food, { id: { $in: foodIds } }, { populate: ['category'] }); + const foodMap = new Map(foods.map(f => [f.id, f])); + + let hasMatchingItem = false; + for (const item of cart.items) { + const food = foodMap.get(item.foodId); + if (!food) continue; + + // Check if item matches coupon criteria + const matchesCategory = + hasCategoryRestriction && food.category && coupon.foodCategories!.includes(food.category.id); + const matchesFood = hasFoodRestriction && coupon.foods!.includes(food.id); + + // Item is eligible if it matches category OR food criteria + if (matchesCategory || matchesFood) { + hasMatchingItem = true; + break; + } + } + + if (!hasMatchingItem) { + throw new BadRequestException( + 'This coupon cannot be applied to the items in your cart. Please add items from the specified categories or foods.', + ); + } + } + const couponDetail: OrderCouponDetail = { couponId: coupon.id, couponName: coupon.name, @@ -562,7 +612,48 @@ export class CartService { let couponDiscount = 0; if (cart.coupon) { - const priceAfterItemDiscount = subTotal - itemsDiscount; + // Get the full coupon entity to check foodCategories and foods + const coupon = await this.couponService.findById(cart.coupon.couponId); + + // Determine which items are eligible for the coupon + let eligibleItemsTotal = 0; + let eligibleItemsDiscount = 0; + + // If coupon has foodCategories or foods restrictions, filter items + const hasCategoryRestriction = coupon.foodCategories && coupon.foodCategories.length > 0; + const hasFoodRestriction = coupon.foods && coupon.foods.length > 0; + + if (hasCategoryRestriction || hasFoodRestriction) { + // Fetch all foods in cart with their categories + const foodIds = cart.items.map(item => item.foodId); + const foods = await this.em.find(Food, { id: { $in: foodIds } }, { populate: ['category'] }); + const foodMap = new Map(foods.map(f => [f.id, f])); + + for (const item of cart.items) { + const food = foodMap.get(item.foodId); + if (!food) continue; + + // Check if item matches coupon criteria + const matchesCategory = + hasCategoryRestriction && food.category && coupon.foodCategories!.includes(food.category.id); + const matchesFood = hasFoodRestriction && coupon.foods!.includes(food.id); + + // Item is eligible if it matches category OR food criteria + if (matchesCategory || matchesFood) { + const itemPrice = item.price * item.quantity; + const itemDiscount = item.discount * item.quantity; + eligibleItemsTotal += itemPrice; + eligibleItemsDiscount += itemDiscount; + } + } + } else { + // No restrictions, apply to all items + eligibleItemsTotal = subTotal; + eligibleItemsDiscount = itemsDiscount; + } + + const priceAfterItemDiscount = eligibleItemsTotal - eligibleItemsDiscount; + if (cart.coupon.type === CouponType.PERCENTAGE) { let discount = (priceAfterItemDiscount * cart.coupon.value) / 100; // Apply maxDiscount limit if set diff --git a/src/modules/coupons/dto/create-coupon.dto.ts b/src/modules/coupons/dto/create-coupon.dto.ts index 73afebe..9ec3a1e 100644 --- a/src/modules/coupons/dto/create-coupon.dto.ts +++ b/src/modules/coupons/dto/create-coupon.dto.ts @@ -1,4 +1,4 @@ -import { IsNotEmpty, IsString, IsEnum, IsNumber, IsOptional, IsBoolean, IsDateString, Min } from 'class-validator'; +import { IsNotEmpty, IsString, IsEnum, IsNumber, IsOptional, IsBoolean, IsDateString, Min, IsArray } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { CouponType } from '../entities/coupon.entity'; @@ -74,5 +74,33 @@ export class CreateCouponDto { @Type(() => Boolean) @ApiPropertyOptional({ example: true, description: 'Whether coupon is active' }) isActive?: boolean; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + @ApiPropertyOptional({ + example: ['category-id-1', 'category-id-2'], + description: 'Array of food category IDs. If empty, coupon applies to all categories.', + type: [String] + }) + foodCategories?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + @ApiPropertyOptional({ + example: ['food-id-1', 'food-id-2'], + description: 'Array of food IDs. If empty, coupon applies to all foods.', + type: [String] + }) + foods?: string[]; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ + example: '09123456789', + description: 'Phone number of the user who can use this coupon. If empty, coupon can be used by any user.', + }) + userPhone?: string; } diff --git a/src/modules/coupons/entities/coupon.entity.ts b/src/modules/coupons/entities/coupon.entity.ts index 2e25505..0ab9bba 100644 --- a/src/modules/coupons/entities/coupon.entity.ts +++ b/src/modules/coupons/entities/coupon.entity.ts @@ -51,4 +51,13 @@ export class Coupon extends BaseEntity { @Property({ type: 'boolean', default: true }) isActive: boolean = true; + + @Property({ type: 'json', nullable: true }) + foodCategories?: string[]; // Array of category IDs + + @Property({ type: 'json', nullable: true }) + foods?: string[]; // Array of food IDs + + @Property({ nullable: true }) + userPhone?: string; // Phone number of the user who can use this coupon } diff --git a/src/modules/coupons/providers/coupon.service.ts b/src/modules/coupons/providers/coupon.service.ts index e60c693..645875a 100644 --- a/src/modules/coupons/providers/coupon.service.ts +++ b/src/modules/coupons/providers/coupon.service.ts @@ -61,6 +61,9 @@ export class CouponService { endDate: createCouponDto.endDate ? new Date(createCouponDto.endDate) : undefined, isActive: createCouponDto.isActive ?? true, usedCount: 0, + foodCategories: createCouponDto.foodCategories, + foods: createCouponDto.foods, + userPhone: createCouponDto.userPhone, }; const coupon = this.couponRepository.create(data); @@ -134,6 +137,9 @@ export class CouponService { if (dto.startDate !== undefined) coupon.startDate = dto.startDate ? new Date(dto.startDate) : undefined; if (dto.endDate !== undefined) coupon.endDate = dto.endDate ? new Date(dto.endDate) : undefined; if (dto.isActive !== undefined) coupon.isActive = dto.isActive; + if (dto.foodCategories !== undefined) coupon.foodCategories = dto.foodCategories; + if (dto.foods !== undefined) coupon.foods = dto.foods; + if (dto.userPhone !== undefined) coupon.userPhone = dto.userPhone; await this.em.persistAndFlush(coupon); return coupon; diff --git a/src/seeders/data/restaurants.data.ts b/src/seeders/data/restaurants.data.ts index 94026b5..fe9a273 100644 --- a/src/seeders/data/restaurants.data.ts +++ b/src/seeders/data/restaurants.data.ts @@ -14,7 +14,7 @@ export const restaurantsData: RestaurantData[] = [ { name: 'ژیوان', slug: 'zhivan', - domain: 'https://www.zhivan.ir', + domain: 'https://dmenu-plus-front.dev.danakcorp.com/zhivan', isActive: true, phone: '09123456789', serviceArea: { @@ -33,7 +33,7 @@ export const restaurantsData: RestaurantData[] = [ { name: 'بوته', slug: 'boote', - domain: 'https://www.boote.ir', + domain: 'https://dmenu-plus-front.dev.danakcorp.com/boote', isActive: true, phone: '09123456790', serviceArea: {