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 { SetPaymentMethodDto } from '../dto/set-payment-method.dto';
|
||||||
import { SetDeliveryMethodDto } from '../dto/set-delivery-method.dto';
|
import { SetDeliveryMethodDto } from '../dto/set-delivery-method.dto';
|
||||||
import { UserAddress } from '../../users/entities/user-address.entity';
|
import { UserAddress } from '../../users/entities/user-address.entity';
|
||||||
|
import { User } from '../../users/entities/user.entity';
|
||||||
import { PaymentMethod } from '../../payments/entities/payment-method.entity';
|
import { PaymentMethod } from '../../payments/entities/payment-method.entity';
|
||||||
import { Delivery } from '../../delivery/entities/delivery.entity';
|
import { Delivery } from '../../delivery/entities/delivery.entity';
|
||||||
import { Cart, CartItem } from '../interfaces/cart.interface';
|
import { Cart, CartItem } from '../interfaces/cart.interface';
|
||||||
@@ -366,20 +367,35 @@ export class CartService {
|
|||||||
|
|
||||||
const orderAmount = cart.subTotal - cart.itemsDiscount;
|
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,
|
applyCouponDto.code,
|
||||||
restaurantId,
|
restaurantId,
|
||||||
Number(orderAmount),
|
Number(orderAmount),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
throw new BadRequestException('Invalid coupon code');
|
throw new BadRequestException(message || 'Invalid coupon code');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!coupon) {
|
if (!coupon) {
|
||||||
throw new BadRequestException('Coupon not found');
|
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
|
// Check maxUsesPerUser limit by counting how many orders this user has with this coupon
|
||||||
if (coupon.maxUsesPerUser) {
|
if (coupon.maxUsesPerUser) {
|
||||||
const result = await this.em.getConnection().execute<{ count: string }>(
|
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 = {
|
const couponDetail: OrderCouponDetail = {
|
||||||
couponId: coupon.id,
|
couponId: coupon.id,
|
||||||
couponName: coupon.name,
|
couponName: coupon.name,
|
||||||
@@ -562,7 +612,48 @@ export class CartService {
|
|||||||
let couponDiscount = 0;
|
let couponDiscount = 0;
|
||||||
|
|
||||||
if (cart.coupon) {
|
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) {
|
if (cart.coupon.type === CouponType.PERCENTAGE) {
|
||||||
let discount = (priceAfterItemDiscount * cart.coupon.value) / 100;
|
let discount = (priceAfterItemDiscount * cart.coupon.value) / 100;
|
||||||
// Apply maxDiscount limit if set
|
// 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 { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { CouponType } from '../entities/coupon.entity';
|
import { CouponType } from '../entities/coupon.entity';
|
||||||
@@ -74,5 +74,33 @@ export class CreateCouponDto {
|
|||||||
@Type(() => Boolean)
|
@Type(() => Boolean)
|
||||||
@ApiPropertyOptional({ example: true, description: 'Whether coupon is active' })
|
@ApiPropertyOptional({ example: true, description: 'Whether coupon is active' })
|
||||||
isActive?: boolean;
|
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 })
|
@Property({ type: 'boolean', default: true })
|
||||||
isActive: boolean = 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,
|
endDate: createCouponDto.endDate ? new Date(createCouponDto.endDate) : undefined,
|
||||||
isActive: createCouponDto.isActive ?? true,
|
isActive: createCouponDto.isActive ?? true,
|
||||||
usedCount: 0,
|
usedCount: 0,
|
||||||
|
foodCategories: createCouponDto.foodCategories,
|
||||||
|
foods: createCouponDto.foods,
|
||||||
|
userPhone: createCouponDto.userPhone,
|
||||||
};
|
};
|
||||||
|
|
||||||
const coupon = this.couponRepository.create(data);
|
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.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.endDate !== undefined) coupon.endDate = dto.endDate ? new Date(dto.endDate) : undefined;
|
||||||
if (dto.isActive !== undefined) coupon.isActive = dto.isActive;
|
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);
|
await this.em.persistAndFlush(coupon);
|
||||||
return coupon;
|
return coupon;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const restaurantsData: RestaurantData[] = [
|
|||||||
{
|
{
|
||||||
name: 'ژیوان',
|
name: 'ژیوان',
|
||||||
slug: 'zhivan',
|
slug: 'zhivan',
|
||||||
domain: 'https://www.zhivan.ir',
|
domain: 'https://dmenu-plus-front.dev.danakcorp.com/zhivan',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
phone: '09123456789',
|
phone: '09123456789',
|
||||||
serviceArea: {
|
serviceArea: {
|
||||||
@@ -33,7 +33,7 @@ export const restaurantsData: RestaurantData[] = [
|
|||||||
{
|
{
|
||||||
name: 'بوته',
|
name: 'بوته',
|
||||||
slug: 'boote',
|
slug: 'boote',
|
||||||
domain: 'https://www.boote.ir',
|
domain: 'https://dmenu-plus-front.dev.danakcorp.com/boote',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
phone: '09123456790',
|
phone: '09123456790',
|
||||||
serviceArea: {
|
serviceArea: {
|
||||||
|
|||||||
Reference in New Issue
Block a user