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 { 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/entities/coupon.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 { 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 { const cart = await this.getCartByRestaurant(userId, restaurantId); if (!cart) { throw new NotFoundException('Cart not found'); } return cart; } async findOne2(userId: string, restaurantId: string): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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) { const result = await this.em.getConnection().execute<{ count: string }>( `SELECT COUNT(*) as count FROM orders WHERE user_id = $1 AND coupon_detail IS NOT NULL AND coupon_detail->>'couponId' = $2 AND deleted_at IS NULL`, [userId, coupon.id], ); const userCouponUsageCount = parseInt((result[0] as { count: string })?.count || '0', 10); 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 { 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 { const cart = await this.findOneOrFail(userId, restaurantId); // 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 { 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 { 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; } /** * Recalculate cart totals (including coupon discount and tax) */ private async recalculateCartTotals(cart: Cart): Promise { 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; 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 { const cacheKey = `${this.CART_KEY_PREFIX}:${userId}:${restaurantId}`; const cachedCart = await this.cacheService.get(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 { 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 { const cacheKey = `${this.CART_KEY_PREFIX}:${userId}:${restaurantId}`; return this.cacheService.del(cacheKey); } }