coupon
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user