779 lines
26 KiB
TypeScript
779 lines
26 KiB
TypeScript
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||
import { Food } from '../../foods/entities/food.entity';
|
||
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||
import { EntityManager } from '@mikro-orm/postgresql';
|
||
import { CacheService } from '../../utils/cache.service';
|
||
import { AddItemToCartDto } from '../dto/add-item.dto';
|
||
import { BulkAddItemsToCartDto } from '../dto/bulk-add-items.dto';
|
||
import { UpdateItemQuantityDto } from '../dto/update-item.dto';
|
||
import { ApplyCouponDto } from '../dto/apply-coupon.dto';
|
||
import { SetAddressDto } from '../dto/set-address.dto';
|
||
import { SetPaymentMethodDto } from '../dto/set-payment-method.dto';
|
||
import { SetDeliveryMethodDto } from '../dto/set-delivery-method.dto';
|
||
import { SetDescriptionDto } from '../dto/set-description.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';
|
||
import { CouponService } from 'src/modules/coupons/providers/coupon.service';
|
||
import { OrderCouponDetail } from 'src/modules/orders/interface/order-status';
|
||
import { CouponType } from 'src/modules/coupons/interface/coupon';
|
||
import { Order } from '../../orders/entities/order.entity';
|
||
|
||
@Injectable()
|
||
export class CartService {
|
||
private readonly CART_TTL = 1200; // 20 minutes in seconds
|
||
private readonly CART_KEY_PREFIX = 'cart';
|
||
|
||
constructor(
|
||
private readonly em: EntityManager,
|
||
private readonly cacheService: CacheService,
|
||
private readonly couponService: CouponService,
|
||
) {}
|
||
|
||
/**
|
||
* Get or create cart for user and restaurant
|
||
*/
|
||
async getOrCreateCart(userId: string, restaurantId: string): Promise<Cart> {
|
||
const existingCart = await this.getCartByRestaurant(userId, restaurantId);
|
||
if (existingCart) {
|
||
return existingCart;
|
||
}
|
||
|
||
// Find restaurant
|
||
const restaurant = await this.em.findOne(Restaurant, { id: restaurantId });
|
||
if (!restaurant) {
|
||
throw new NotFoundException('Restaurant not found');
|
||
}
|
||
|
||
// Create new cart
|
||
const now = new Date().toISOString();
|
||
const cart: Cart = {
|
||
userId,
|
||
restaurantId,
|
||
restaurantName: restaurant.name,
|
||
items: [],
|
||
deliveryFee: 0,
|
||
subTotal: 0,
|
||
itemsDiscount: 0,
|
||
couponDiscount: 0,
|
||
totalDiscount: 0,
|
||
tax: 0,
|
||
total: 0,
|
||
totalItems: 0,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
};
|
||
|
||
await this.saveCart(cart);
|
||
return cart;
|
||
}
|
||
|
||
/**
|
||
* Find cart by user and restaurant
|
||
*/
|
||
async findOneOrFail(userId: string, restaurantId: string): Promise<Cart> {
|
||
const cart = await this.getCartByRestaurant(userId, restaurantId);
|
||
if (!cart) {
|
||
throw new NotFoundException('Cart not found');
|
||
}
|
||
return cart;
|
||
}
|
||
|
||
async findOne2(userId: string, restaurantId: string): Promise<Cart | null> {
|
||
const cart = await this.getCartByRestaurant(userId, restaurantId);
|
||
return cart;
|
||
}
|
||
|
||
/**
|
||
* Add item to cart (increment quantity if item exists, otherwise create new)
|
||
*/
|
||
async addItem(userId: string, restaurantId: string, foodId: string, addItemDto: AddItemToCartDto): Promise<Cart> {
|
||
const cart = await this.getOrCreateCart(userId, restaurantId);
|
||
|
||
// Find food
|
||
const food = await this.em.findOne(Food, { id: foodId }, { populate: ['restaurant'] });
|
||
if (!food) {
|
||
throw new NotFoundException(`Food with ID ${foodId} not found`);
|
||
}
|
||
|
||
// Check if food belongs to the restaurant
|
||
if (food.restaurant.id !== restaurantId) {
|
||
throw new BadRequestException(`Food ${food.title || food.id} does not belong to restaurant ${restaurantId}`);
|
||
}
|
||
|
||
// Check stock availability
|
||
if (food.stock < addItemDto.quantity) {
|
||
throw new BadRequestException(`Insufficient stock for food ${food.title}. Available: ${food.stock}`);
|
||
}
|
||
|
||
// Check if item already exists in cart
|
||
const existingItemIndex = cart.items.findIndex(item => item.foodId === foodId);
|
||
|
||
if (existingItemIndex >= 0) {
|
||
// Update existing item quantity
|
||
const existingItem = cart.items[existingItemIndex];
|
||
const newQuantity = existingItem.quantity + addItemDto.quantity;
|
||
|
||
// Check stock for new total quantity
|
||
if (food.stock < newQuantity) {
|
||
throw new BadRequestException(`Insufficient stock for food ${food.title}. Available: ${food.stock}`);
|
||
}
|
||
|
||
const itemPrice = food.price || 0;
|
||
const itemDiscount = food.discount || 0;
|
||
const itemTotalPrice = itemPrice * newQuantity;
|
||
const itemTotalDiscount = itemDiscount * newQuantity;
|
||
const itemFinalPrice = itemTotalPrice - itemTotalDiscount;
|
||
|
||
cart.items[existingItemIndex] = {
|
||
...existingItem,
|
||
quantity: newQuantity,
|
||
totalPrice: itemFinalPrice,
|
||
};
|
||
} else {
|
||
// Create new item
|
||
const itemPrice = food.price || 0;
|
||
const itemDiscount = food.discount || 0;
|
||
const itemTotalPrice = itemPrice * addItemDto.quantity;
|
||
const itemTotalDiscount = itemDiscount * addItemDto.quantity;
|
||
const itemFinalPrice = itemTotalPrice - itemTotalDiscount;
|
||
|
||
const cartItem: CartItem = {
|
||
foodId: food.id,
|
||
foodTitle: food.title,
|
||
quantity: addItemDto.quantity,
|
||
price: itemPrice,
|
||
discount: itemDiscount,
|
||
totalPrice: itemFinalPrice,
|
||
};
|
||
|
||
cart.items.push(cartItem);
|
||
}
|
||
|
||
// Recalculate cart totals
|
||
await this.recalculateCartTotals(cart);
|
||
await this.saveCart(cart);
|
||
|
||
return cart;
|
||
}
|
||
|
||
/**
|
||
* Increment item quantity in cart by 1
|
||
*/
|
||
async incrementItem(userId: string, restaurantId: string, foodId: string): Promise<Cart> {
|
||
return this.addItem(userId, restaurantId, foodId, { foodId, quantity: 1 });
|
||
}
|
||
|
||
/**
|
||
* Decrement item quantity in cart by 1 (removes item if quantity reaches 0)
|
||
*/
|
||
async decrementItem(userId: string, restaurantId: string, foodId: string): Promise<Cart> {
|
||
const cart = await this.findOneOrFail(userId, restaurantId);
|
||
|
||
const itemIndex = cart.items.findIndex(item => item.foodId === foodId);
|
||
if (itemIndex < 0) {
|
||
throw new NotFoundException(`Item with food ID ${foodId} not found in cart`);
|
||
}
|
||
|
||
const existingItem = cart.items[itemIndex];
|
||
const newQuantity = existingItem.quantity - 1;
|
||
|
||
if (newQuantity <= 0) {
|
||
// Remove item if quantity becomes 0 or less
|
||
cart.items.splice(itemIndex, 1);
|
||
} else {
|
||
// Update item quantity and recalculate price
|
||
const food = await this.em.findOne(Food, { id: foodId });
|
||
if (!food) {
|
||
throw new NotFoundException(`Food with ID ${foodId} not found`);
|
||
}
|
||
|
||
const itemPrice = food.price || 0;
|
||
const itemDiscount = food.discount || 0;
|
||
const itemTotalPrice = itemPrice * newQuantity;
|
||
const itemTotalDiscount = itemDiscount * newQuantity;
|
||
const itemFinalPrice = itemTotalPrice - itemTotalDiscount;
|
||
|
||
cart.items[itemIndex] = {
|
||
...existingItem,
|
||
quantity: newQuantity,
|
||
totalPrice: itemFinalPrice,
|
||
};
|
||
}
|
||
|
||
// Recalculate cart totals
|
||
await this.recalculateCartTotals(cart);
|
||
await this.saveCart(cart);
|
||
|
||
return cart;
|
||
}
|
||
|
||
/**
|
||
* Bulk add items to cart (increments quantity if items exist)
|
||
*/
|
||
async bulkAddItems(userId: string, restaurantId: string, bulkAddItemsDto: BulkAddItemsToCartDto): Promise<Cart> {
|
||
const cart = await this.getOrCreateCart(userId, restaurantId);
|
||
|
||
// Process each item
|
||
for (const addItemDto of bulkAddItemsDto.items) {
|
||
// Find food
|
||
const food = await this.em.findOne(Food, { id: addItemDto.foodId }, { populate: ['restaurant'] });
|
||
if (!food) {
|
||
throw new NotFoundException(`Food with ID ${addItemDto.foodId} not found`);
|
||
}
|
||
|
||
// Check if food belongs to the restaurant
|
||
if (food.restaurant.id !== restaurantId) {
|
||
throw new BadRequestException(`Food ${food.title || food.id} does not belong to restaurant ${restaurantId}`);
|
||
}
|
||
|
||
// Check stock availability
|
||
if (food.stock < addItemDto.quantity) {
|
||
throw new BadRequestException(`Insufficient stock for food ${food.title}. Available: ${food.stock}`);
|
||
}
|
||
|
||
// Check if item already exists in cart
|
||
const existingItemIndex = cart.items.findIndex(item => item.foodId === addItemDto.foodId);
|
||
|
||
if (existingItemIndex >= 0) {
|
||
// Update existing item quantity
|
||
const existingItem = cart.items[existingItemIndex];
|
||
const newQuantity = existingItem.quantity + addItemDto.quantity;
|
||
|
||
// Check stock for new total quantity
|
||
if (food.stock < newQuantity) {
|
||
throw new BadRequestException(`Insufficient stock for food ${food.title}. Available: ${food.stock}`);
|
||
}
|
||
|
||
const itemPrice = food.price || 0;
|
||
const itemDiscount = food.discount || 0;
|
||
const itemTotalPrice = itemPrice * newQuantity;
|
||
const itemTotalDiscount = itemDiscount * newQuantity;
|
||
const itemFinalPrice = itemTotalPrice - itemTotalDiscount;
|
||
|
||
cart.items[existingItemIndex] = {
|
||
...existingItem,
|
||
quantity: newQuantity,
|
||
totalPrice: itemFinalPrice,
|
||
};
|
||
} else {
|
||
// Create new item
|
||
const itemPrice = food.price || 0;
|
||
const itemDiscount = food.discount || 0;
|
||
const itemTotalPrice = itemPrice * addItemDto.quantity;
|
||
const itemTotalDiscount = itemDiscount * addItemDto.quantity;
|
||
const itemFinalPrice = itemTotalPrice - itemTotalDiscount;
|
||
|
||
const cartItem: CartItem = {
|
||
foodId: food.id,
|
||
foodTitle: food.title,
|
||
quantity: addItemDto.quantity,
|
||
price: itemPrice,
|
||
discount: itemDiscount,
|
||
totalPrice: itemFinalPrice,
|
||
};
|
||
|
||
cart.items.push(cartItem);
|
||
}
|
||
}
|
||
|
||
// Recalculate cart totals once after all items are added
|
||
await this.recalculateCartTotals(cart);
|
||
await this.saveCart(cart);
|
||
|
||
return cart;
|
||
}
|
||
|
||
/**
|
||
* Update item quantity in cart
|
||
*/
|
||
async updateItemQuantity(
|
||
userId: string,
|
||
restaurantId: string,
|
||
foodId: string,
|
||
updateItemDto: UpdateItemQuantityDto,
|
||
): Promise<Cart> {
|
||
const cart = await this.findOneOrFail(userId, restaurantId);
|
||
|
||
const itemIndex = cart.items.findIndex(item => item.foodId === foodId);
|
||
if (itemIndex < 0) {
|
||
throw new NotFoundException(`Item with food ID ${foodId} not found in cart`);
|
||
}
|
||
|
||
const item = cart.items[itemIndex];
|
||
|
||
// Find food to check stock
|
||
const food = await this.em.findOne(Food, { id: item.foodId }, { populate: ['restaurant'] });
|
||
if (!food) {
|
||
throw new NotFoundException(`Food with ID ${item.foodId} not found`);
|
||
}
|
||
|
||
// Check stock availability
|
||
if (food.stock < updateItemDto.quantity) {
|
||
throw new BadRequestException(`Insufficient stock for food ${food.title}. Available: ${food.stock}`);
|
||
}
|
||
|
||
// Update quantity
|
||
const itemPrice = food.price || 0;
|
||
const itemDiscount = food.discount || 0;
|
||
const itemTotalPrice = itemPrice * updateItemDto.quantity;
|
||
const itemTotalDiscount = itemDiscount * updateItemDto.quantity;
|
||
const itemFinalPrice = itemTotalPrice - itemTotalDiscount;
|
||
|
||
cart.items[itemIndex] = {
|
||
...item,
|
||
quantity: updateItemDto.quantity,
|
||
totalPrice: itemFinalPrice,
|
||
};
|
||
|
||
// Recalculate cart totals
|
||
await this.recalculateCartTotals(cart);
|
||
await this.saveCart(cart);
|
||
|
||
return cart;
|
||
}
|
||
|
||
/**
|
||
* Remove item from cart
|
||
*/
|
||
async removeItem(userId: string, restaurantId: string, foodId: string): Promise<Cart> {
|
||
const cart = await this.findOneOrFail(userId, restaurantId);
|
||
|
||
const itemIndex = cart.items.findIndex(item => item.foodId === foodId);
|
||
if (itemIndex < 0) {
|
||
throw new NotFoundException(`Item with food ID ${foodId} not found in cart`);
|
||
}
|
||
|
||
// Remove item
|
||
cart.items.splice(itemIndex, 1);
|
||
|
||
// Recalculate cart totals
|
||
await this.recalculateCartTotals(cart);
|
||
await this.saveCart(cart);
|
||
|
||
return cart;
|
||
}
|
||
|
||
/**
|
||
* Apply coupon to cart
|
||
*/
|
||
async applyCoupon(userId: string, restaurantId: string, applyCouponDto: ApplyCouponDto): Promise<Cart> {
|
||
const cart = await this.findOneOrFail(userId, restaurantId);
|
||
|
||
// Calculate current order amount (subtotal after item discounts) for coupon validation
|
||
// const currentSubTotal = cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
|
||
// const currentItemsDiscount = cart.items.reduce((sum, item) => sum + item.discount * item.quantity, 0);
|
||
// const orderAmount = currentSubTotal - currentItemsDiscount;
|
||
|
||
const orderAmount = cart.subTotal - cart.itemsDiscount;
|
||
|
||
// 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(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) {
|
||
// Use native query for JSONB field access with proper parameter binding
|
||
const knex = this.em.getKnex();
|
||
const result = await knex.raw(
|
||
`SELECT COUNT(*)::int as count
|
||
FROM orders
|
||
WHERE user_id = ?
|
||
AND coupon_detail IS NOT NULL
|
||
AND coupon_detail->>'couponId' = ?
|
||
AND deleted_at IS NULL`,
|
||
[userId, coupon.id],
|
||
);
|
||
|
||
const userCouponUsageCount = result.rows?.[0]?.count ?? result[0]?.count ?? 0;
|
||
|
||
if (userCouponUsageCount >= coupon.maxUsesPerUser) {
|
||
throw new BadRequestException(
|
||
`You have reached the maximum number of uses (${coupon.maxUsesPerUser}) for this coupon`,
|
||
);
|
||
}
|
||
}
|
||
|
||
// 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,
|
||
couponCode: coupon.code,
|
||
type: coupon.type,
|
||
value: coupon.value,
|
||
maxDiscount: coupon.maxDiscount,
|
||
};
|
||
|
||
cart.coupon = couponDetail;
|
||
|
||
// Recalculate cart totals
|
||
await this.recalculateCartTotals(cart);
|
||
await this.saveCart(cart);
|
||
|
||
return cart;
|
||
}
|
||
|
||
/**
|
||
* Remove coupon from cart
|
||
*/
|
||
async removeCoupon(userId: string, restaurantId: string): Promise<Cart> {
|
||
const cart = await this.findOneOrFail(userId, restaurantId);
|
||
|
||
cart.coupon = undefined;
|
||
|
||
// Recalculate cart totals
|
||
await this.recalculateCartTotals(cart);
|
||
await this.saveCart(cart);
|
||
|
||
return cart;
|
||
}
|
||
|
||
/**
|
||
* Set address for cart
|
||
*/
|
||
async setAddress(userId: string, restaurantId: string, setAddressDto: SetAddressDto): Promise<Cart> {
|
||
const cart = await this.findOneOrFail(userId, restaurantId);
|
||
|
||
if (!cart.deliveryMethodId) throw new BadRequestException('Delivery method must be set before setting address');
|
||
// Find and validate address belongs to user
|
||
const address = await this.em.findOne(UserAddress, { id: setAddressDto.addressId }, { populate: ['user'] });
|
||
if (!address) {
|
||
throw new NotFoundException(`Address with ID ${setAddressDto.addressId} not found`);
|
||
}
|
||
|
||
// Verify address belongs to the user
|
||
if (address.user.id !== userId) {
|
||
throw new BadRequestException('Address does not belong to the current user');
|
||
}
|
||
|
||
cart.addressId = setAddressDto.addressId;
|
||
cart.updatedAt = new Date().toISOString();
|
||
|
||
await this.saveCart(cart);
|
||
|
||
return cart;
|
||
}
|
||
|
||
/**
|
||
* Set payment method for cart
|
||
*/
|
||
async setPaymentMethod(
|
||
userId: string,
|
||
restaurantId: string,
|
||
setPaymentMethodDto: SetPaymentMethodDto,
|
||
): Promise<Cart> {
|
||
const cart = await this.findOneOrFail(userId, restaurantId);
|
||
|
||
// Find and validate payment method belongs to restaurant
|
||
const paymentMethod = await this.em.findOne(
|
||
PaymentMethod,
|
||
{
|
||
id: setPaymentMethodDto.paymentMethodId,
|
||
restaurant: { id: restaurantId },
|
||
},
|
||
{ populate: ['restaurant'] },
|
||
);
|
||
|
||
if (!paymentMethod) {
|
||
throw new NotFoundException(
|
||
`Payment method with ID ${setPaymentMethodDto.paymentMethodId} not found for restaurant ${restaurantId}`,
|
||
);
|
||
}
|
||
|
||
// Verify payment method is enabled
|
||
if (!paymentMethod.enabled) {
|
||
throw new BadRequestException('Payment method is not enabled for this restaurant');
|
||
}
|
||
|
||
cart.paymentMethodId = setPaymentMethodDto.paymentMethodId;
|
||
|
||
// Recalculate cart totals
|
||
await this.recalculateCartTotals(cart);
|
||
await this.saveCart(cart);
|
||
|
||
return cart;
|
||
}
|
||
|
||
/**
|
||
* Set delivery method for cart
|
||
*/
|
||
async setDeliveryMethod(
|
||
userId: string,
|
||
restaurantId: string,
|
||
setDeliveryMethodDto: SetDeliveryMethodDto,
|
||
): Promise<Cart> {
|
||
const cart = await this.findOneOrFail(userId, restaurantId);
|
||
|
||
// Find and validate delivery method belongs to restaurant
|
||
const deliveryMethod = await this.em.findOne(
|
||
Delivery,
|
||
{
|
||
id: setDeliveryMethodDto.deliveryMethodId,
|
||
restaurant: { id: restaurantId },
|
||
},
|
||
{ populate: ['restaurant'] },
|
||
);
|
||
|
||
if (!deliveryMethod) {
|
||
throw new NotFoundException(
|
||
`Delivery method with ID ${setDeliveryMethodDto.deliveryMethodId} not found for restaurant ${restaurantId}`,
|
||
);
|
||
}
|
||
|
||
// Verify delivery method is enabled
|
||
if (!deliveryMethod.enabled) {
|
||
throw new BadRequestException('Delivery method is not enabled for this restaurant');
|
||
}
|
||
|
||
cart.deliveryMethodId = setDeliveryMethodDto.deliveryMethodId;
|
||
|
||
// Recalculate cart totals (this will update deliveryFee based on delivery method)
|
||
await this.recalculateCartTotals(cart);
|
||
await this.saveCart(cart);
|
||
|
||
return cart;
|
||
}
|
||
|
||
/**
|
||
* Set description for cart
|
||
*/
|
||
async setDescription(userId: string, restaurantId: string, setDescriptionDto: SetDescriptionDto): Promise<Cart> {
|
||
const cart = await this.findOneOrFail(userId, restaurantId);
|
||
|
||
cart.description = setDescriptionDto.description;
|
||
cart.updatedAt = new Date().toISOString();
|
||
|
||
await this.saveCart(cart);
|
||
|
||
return cart;
|
||
}
|
||
|
||
/**
|
||
* Recalculate cart totals (including coupon discount and tax)
|
||
*/
|
||
private async recalculateCartTotals(cart: Cart): Promise<void> {
|
||
let subTotal = 0;
|
||
let itemsDiscount = 0;
|
||
let totalItems = 0;
|
||
|
||
for (const item of cart.items) {
|
||
const itemPrice = item.price * item.quantity;
|
||
const itemDiscount = item.discount * item.quantity;
|
||
subTotal += itemPrice;
|
||
itemsDiscount += itemDiscount;
|
||
totalItems += item.quantity;
|
||
}
|
||
|
||
cart.subTotal = subTotal;
|
||
cart.itemsDiscount = itemsDiscount;
|
||
|
||
// Calculate coupon discount
|
||
let couponDiscount = 0;
|
||
|
||
if (cart.coupon) {
|
||
// 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
|
||
if (cart.coupon.maxDiscount && discount > cart.coupon.maxDiscount) {
|
||
discount = cart.coupon.maxDiscount;
|
||
}
|
||
couponDiscount = discount;
|
||
} else {
|
||
// FIXED amount discount
|
||
couponDiscount = Math.min(cart.coupon.value, priceAfterItemDiscount);
|
||
}
|
||
}
|
||
|
||
cart.couponDiscount = couponDiscount;
|
||
// Calculate total discount: totalDiscount = couponDiscount + itemsDiscount
|
||
cart.totalDiscount = couponDiscount + itemsDiscount;
|
||
|
||
// Calculate tax if restaurant has VAT rate > 0
|
||
let tax = 0;
|
||
const restaurant = await this.em.findOne(Restaurant, { id: cart.restaurantId });
|
||
if (restaurant && restaurant.vat && restaurant.vat > 0) {
|
||
// Tax is calculated on the price after discounts
|
||
const priceAfterDiscounts = subTotal - cart.totalDiscount;
|
||
tax = (priceAfterDiscounts * Number(restaurant.vat)) / 100;
|
||
}
|
||
|
||
cart.tax = tax;
|
||
|
||
// Shipment fee is calculated separately via delivery method
|
||
let deliveryFee = 0;
|
||
if (cart.deliveryMethodId) {
|
||
const deliveryMethod = await this.em.findOne(Delivery, { id: cart.deliveryMethodId });
|
||
if (deliveryMethod && deliveryMethod.enabled) {
|
||
deliveryFee = Number(deliveryMethod.deliveryFee) || 0;
|
||
}
|
||
}
|
||
cart.deliveryFee = deliveryFee;
|
||
|
||
// Calculate total: total = subtotal – totalDiscount + tax + deliveryFee
|
||
cart.total = subTotal - cart.totalDiscount + tax + cart.deliveryFee;
|
||
cart.totalItems = totalItems;
|
||
cart.updatedAt = new Date().toISOString();
|
||
}
|
||
|
||
/**
|
||
* Type guard to check if an object is a Cart
|
||
*/
|
||
private isCart(obj: unknown): obj is Cart {
|
||
if (typeof obj !== 'object' || obj === null) {
|
||
return false;
|
||
}
|
||
const cart = obj as Record<string, unknown>;
|
||
return (
|
||
typeof cart.userId === 'string' &&
|
||
typeof cart.restaurantId === 'string' &&
|
||
Array.isArray(cart.items) &&
|
||
typeof cart.subTotal === 'number' &&
|
||
typeof cart.itemsDiscount === 'number' &&
|
||
typeof cart.couponDiscount === 'number' &&
|
||
typeof cart.totalDiscount === 'number' &&
|
||
typeof cart.tax === 'number' &&
|
||
typeof cart.deliveryFee === 'number' &&
|
||
typeof cart.total === 'number' &&
|
||
typeof cart.totalItems === 'number' &&
|
||
typeof cart.createdAt === 'string' &&
|
||
typeof cart.updatedAt === 'string'
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Get cart by restaurant for a user
|
||
*/
|
||
private async getCartByRestaurant(userId: string, restaurantId: string): Promise<Cart | null> {
|
||
const cacheKey = `${this.CART_KEY_PREFIX}:${userId}:${restaurantId}`;
|
||
const cachedCart = await this.cacheService.get<string>(cacheKey);
|
||
|
||
if (cachedCart) {
|
||
try {
|
||
const parsed: unknown = JSON.parse(cachedCart);
|
||
if (this.isCart(parsed) && parsed.userId === userId && parsed.restaurantId === restaurantId) {
|
||
return parsed;
|
||
}
|
||
} catch {
|
||
// If parsing fails, return null
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Save cart to cache
|
||
*/
|
||
private async saveCart(cart: Cart): Promise<void> {
|
||
const cacheKey = `${this.CART_KEY_PREFIX}:${cart.userId}:${cart.restaurantId}`;
|
||
await this.cacheService.set(cacheKey, JSON.stringify(cart), this.CART_TTL);
|
||
}
|
||
|
||
async clearCart(userId: string, restaurantId: string): Promise<void> {
|
||
const cacheKey = `${this.CART_KEY_PREFIX}:${userId}:${restaurantId}`;
|
||
return this.cacheService.del(cacheKey);
|
||
}
|
||
}
|