This commit is contained in:
2025-12-06 22:05:56 +03:30
parent e342c00e25
commit f36125764d
5 changed files with 140 additions and 6 deletions
+94 -3
View File
@@ -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
+29 -1
View File
@@ -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;
}
@@ -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
}
@@ -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;
+2 -2
View File
@@ -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: {