diff --git a/src/modules/cart/providers/cart.service.ts b/src/modules/cart/providers/cart.service.ts index d91fd1d..01e7cbe 100644 --- a/src/modules/cart/providers/cart.service.ts +++ b/src/modules/cart/providers/cart.service.ts @@ -23,7 +23,7 @@ import { CouponType } from 'src/modules/coupons/interface/coupon'; @Injectable() export class CartService { - private readonly CART_TTL = 1200; // 20 minutes in seconds + private readonly CART_TTL = 3600; // 1 hour in seconds private readonly CART_KEY_PREFIX = 'cart'; constructor( @@ -48,7 +48,7 @@ export class CartService { } // Create new cart - const now = new Date().toISOString(); + const now = this.nowIso(); const cart: Cart = { userId, restaurantId, @@ -88,29 +88,8 @@ export class CartService { const cart = await this.getOrCreateCart(userId, restaurantId); const food = await this.validateAndGetFood(foodId, restaurantId, quantity); - const existingItemIndex = cart.items.findIndex(item => item.foodId === foodId); - - if (existingItemIndex >= 0) { - const existingItem = cart.items[existingItemIndex]; - const newQuantity = existingItem.quantity + quantity; - - this.validateStock(food, newQuantity); - cart.items[existingItemIndex] = { - ...existingItem, - foodTitle: food.title, - price: food.price || 0, - discount: food.discount || 0, - quantity: newQuantity, - totalPrice: this.calculateItemTotalPrice(food.price || 0, food.discount || 0, newQuantity), - }; - } else { - cart.items.push(this.createCartItem(food, quantity)); - } - - await this.recalculateCartTotals(cart); - await this.saveCart(cart); - - return cart; + this.addOrIncrementItem(cart, food, quantity); + return this.recalculateAndSaveCart(cart); } /** @@ -118,39 +97,8 @@ export class CartService { */ 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 { - const food = await this.em.findOne(Food, { id: foodId }); - if (!food) { - throw new NotFoundException(`Food with ID ${foodId} not found`); - } - - cart.items[itemIndex] = { - ...existingItem, - foodTitle: food.title, - price: food.price || 0, - discount: food.discount || 0, - quantity: newQuantity, - totalPrice: this.calculateItemTotalPrice(food.price || 0, food.discount || 0, newQuantity), - }; - } - - // Recalculate cart totals - await this.recalculateCartTotals(cart); - await this.saveCart(cart); - - return cart; + await this.decrementOrRemoveItem(cart, foodId); + return this.recalculateAndSaveCart(cart); } /** @@ -161,30 +109,10 @@ export class CartService { for (const addItemDto of bulkAddItemsDto.items) { const food = await this.validateAndGetFood(addItemDto.foodId, restaurantId, addItemDto.quantity); - const existingItemIndex = cart.items.findIndex(item => item.foodId === addItemDto.foodId); - - if (existingItemIndex >= 0) { - const existingItem = cart.items[existingItemIndex]; - const newQuantity = existingItem.quantity + addItemDto.quantity; - - this.validateStock(food, newQuantity); - cart.items[existingItemIndex] = { - ...existingItem, - foodTitle: food.title, - price: food.price || 0, - discount: food.discount || 0, - quantity: newQuantity, - totalPrice: this.calculateItemTotalPrice(food.price || 0, food.discount || 0, newQuantity), - }; - } else { - cart.items.push(this.createCartItem(food, addItemDto.quantity)); - } + this.addOrIncrementItem(cart, food, addItemDto.quantity); } - await this.recalculateCartTotals(cart); - await this.saveCart(cart); - - return cart; + return this.recalculateAndSaveCart(cart); } /** @@ -192,20 +120,8 @@ export class CartService { */ 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; + this.removeItemOrFail(cart, foodId); + return this.recalculateAndSaveCart(cart); } /** @@ -215,10 +131,7 @@ export class CartService { const cart = await this.findOneOrFail(userId, restaurantId); const orderAmount = cart.subTotal - cart.itemsDiscount; - const user = await this.em.findOne(User, { id: userId }); - if (!user) { - throw new NotFoundException(`User with ID ${userId} not found`); - } + const user = await this.getUserOrFail(userId); // check if coupon is valid and belong to the restaurant const { valid, coupon, message } = await this.couponService.validateCoupon( applyCouponDto.code, @@ -236,61 +149,8 @@ export class CartService { } // 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.', - ); - } - } + await this.assertCouponUsageLimit(userId, coupon.id, coupon.maxUsesPerUser); + await this.assertCouponMatchesCartIfRestricted(cart, coupon.foodCategories, coupon.foods); const couponDetail: OrderCouponDetail = { couponId: coupon.id, @@ -302,12 +162,7 @@ export class CartService { }; cart.coupon = couponDetail; - - // Recalculate cart totals - await this.recalculateCartTotals(cart); - await this.saveCart(cart); - - return cart; + return this.recalculateAndSaveCart(cart); } /** @@ -317,12 +172,7 @@ export class CartService { const cart = await this.findOneOrFail(userId, restaurantId); cart.coupon = undefined; - - // Recalculate cart totals - await this.recalculateCartTotals(cart); - await this.saveCart(cart); - - return cart; + return this.recalculateAndSaveCart(cart); } /** @@ -331,31 +181,22 @@ export class CartService { async setAddress(userId: string, restaurantId: string, setAddressDto: SetAddressDto): Promise { const cart = await this.findOneOrFail(userId, restaurantId); - if (!cart.deliveryMethodId) throw new BadRequestException('Delivery method must be set before setting address'); - const deliveryMethod = await this.em.findOne(Delivery, { - id: cart.deliveryMethodId, - restaurant: { id: restaurantId }, - }); - if (!deliveryMethod) { - throw new NotFoundException( - `Delivery method with ID ${cart.deliveryMethodId} not found for restaurant ${restaurantId}`, - ); - } - if (deliveryMethod.method !== DeliveryMethodEnum.DeliveryCourier) { - throw new BadRequestException('Delivery method must be DeliveryCourier'); - } + const deliveryMethodId = this.requireDeliveryMethodId( + cart, + 'Delivery method must be set before setting address', + ); + const deliveryMethod = await this.getDeliveryMethodForRestaurantOrFail(restaurantId, deliveryMethodId); + this.assertDeliveryMethod(deliveryMethod.method, DeliveryMethodEnum.DeliveryCourier, 'Delivery method must be DeliveryCourier'); // 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`); - } + const address = await this.getUserAddressOrFail(setAddressDto.addressId); // Verify address belongs to the user if (address.user.id !== userId) { throw new BadRequestException('Address does not belong to the current user'); } + this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DeliveryCourier); cart.userAddress = { address: address.address, latitude: address.latitude, @@ -366,14 +207,7 @@ export class CartService { fullName: `${address.user.firstName || ''} ${address.user.lastName || ''}`.trim(), phone: address.user.phone, }; - // DeliveryCourier implies: no car + no dine-in table - cart.carAddress = null; - cart.tableNumber = undefined; - cart.updatedAt = new Date().toISOString(); - - await this.saveCart(cart); - - return cart; + return this.saveTouchedCart(cart); } /** @@ -386,34 +220,10 @@ export class CartService { ): 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'); - } + await this.getEnabledPaymentMethodOrFail(restaurantId, setPaymentMethodDto.paymentMethodId); cart.paymentMethodId = setPaymentMethodDto.paymentMethodId; - - // Recalculate cart totals - await this.recalculateCartTotals(cart); - await this.saveCart(cart); - - return cart; + return this.recalculateAndSaveCart(cart); } /** @@ -426,35 +236,12 @@ export class CartService { ): 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'); - } + const deliveryMethod = await this.getEnabledDeliveryMethodOrFail(restaurantId, setDeliveryMethodDto.deliveryMethodId); cart.deliveryMethodId = setDeliveryMethodDto.deliveryMethodId; this.clearFieldsIncompatibleWithDeliveryMethod(cart, deliveryMethod.method); - // Recalculate cart totals (this will update deliveryFee based on delivery method) - await this.recalculateCartTotals(cart); - await this.saveCart(cart); - - return cart; + return this.recalculateAndSaveCart(cart); } /** @@ -464,11 +251,7 @@ export class CartService { const cart = await this.findOneOrFail(userId, restaurantId); cart.description = setDescriptionDto.description; - cart.updatedAt = new Date().toISOString(); - - await this.saveCart(cart); - - return cart; + return this.saveTouchedCart(cart); } /** @@ -477,31 +260,16 @@ export class CartService { async setTableNumber(userId: string, restaurantId: string, setTableNumberDto: SetTableNumberDto): Promise { const cart = await this.findOneOrFail(userId, restaurantId); - if (!cart.deliveryMethodId) { - throw new BadRequestException('Delivery method must be set before setting table number'); - } - const deliveryMethod = await this.em.findOne(Delivery, { - id: cart.deliveryMethodId, - restaurant: { id: restaurantId }, - }); - if (!deliveryMethod) { - throw new NotFoundException( - `Delivery method with ID ${cart.deliveryMethodId} not found for restaurant ${restaurantId}`, - ); - } - if (deliveryMethod.method !== DeliveryMethodEnum.DineIn) { - throw new BadRequestException('Delivery method must be DineIn'); - } + const deliveryMethodId = this.requireDeliveryMethodId( + cart, + 'Delivery method must be set before setting table number', + ); + const deliveryMethod = await this.getDeliveryMethodForRestaurantOrFail(restaurantId, deliveryMethodId); + this.assertDeliveryMethod(deliveryMethod.method, DeliveryMethodEnum.DineIn, 'Delivery method must be DineIn'); + this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DineIn); cart.tableNumber = setTableNumberDto.tableNumber; - // DineIn implies: no delivery addresses - cart.userAddress = null; - cart.carAddress = null; - cart.updatedAt = new Date().toISOString(); - - await this.saveCart(cart); - - return cart; + return this.saveTouchedCart(cart); } /** @@ -510,202 +278,43 @@ export class CartService { async setCarDelivery(userId: string, restaurantId: string, setCarDeliveryDto: SetCarDeliveryDto): Promise { const cart = await this.findOneOrFail(userId, restaurantId); - if (!cart.deliveryMethodId) - throw new BadRequestException('Delivery method must be set before setting car delivery'); - const deliveryMethod = await this.em.findOne(Delivery, { - id: cart.deliveryMethodId, - restaurant: { id: restaurantId }, - }); - - if (!deliveryMethod) { - throw new NotFoundException( - `Delivery method with ID ${cart.deliveryMethodId} not found for restaurant ${restaurantId}`, - ); - } - if (deliveryMethod.method !== DeliveryMethodEnum.DeliveryCar) { - throw new BadRequestException('Delivery method must be DeliveryCar'); - } - const user = await this.em.findOne(User, { id: userId }); - if (!user) { - throw new NotFoundException(`User with ID ${userId} not found`); - } + const deliveryMethodId = this.requireDeliveryMethodId( + cart, + 'Delivery method must be set before setting car delivery', + ); + const deliveryMethod = await this.getDeliveryMethodForRestaurantOrFail(restaurantId, deliveryMethodId); + this.assertDeliveryMethod(deliveryMethod.method, DeliveryMethodEnum.DeliveryCar, 'Delivery method must be DeliveryCar'); + const user = await this.getUserOrFail(userId); + this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DeliveryCar); cart.carAddress = { carModel: setCarDeliveryDto.carModel, carColor: setCarDeliveryDto.carColor, plateNumber: setCarDeliveryDto.plateNumber, phone: user.phone, }; - // DeliveryCar implies: no courier address + no dine-in table - cart.userAddress = null; - cart.tableNumber = undefined; - cart.updatedAt = new Date().toISOString(); - - await this.saveCart(cart); - - return cart; + return this.saveTouchedCart(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 unitPrice = Number(item.price) || 0; - const unitDiscount = Math.min(Number(item.discount) || 0, unitPrice); - const itemPrice = unitPrice * item.quantity; - const itemDiscount = unitDiscount * item.quantity; - subTotal += itemPrice; - itemsDiscount += itemDiscount; - totalItems += item.quantity; - - // Keep item.totalPrice consistent with price/discount/quantity - item.totalPrice = itemPrice - itemDiscount; - } - + const { subTotal, itemsDiscount, totalItems } = this.calculateItemsTotals(cart); cart.subTotal = subTotal; cart.itemsDiscount = itemsDiscount; - // Calculate coupon discount - let couponDiscount = 0; - - if (cart.coupon) { - // Get the full coupon entity to check restrictions. - // If coupon was deleted/disabled, fail gracefully by removing it from cart. - let coupon: - | { - id: string; - type: CouponType; - value: number; - maxDiscount?: number; - isActive?: boolean; - startDate?: Date; - endDate?: Date; - foodCategories?: string[]; - foods?: string[]; - } - | undefined; - - try { - const c = await this.couponService.findById(cart.coupon.couponId); - coupon = { - id: c.id, - type: c.type, - value: c.value, - maxDiscount: c.maxDiscount, - isActive: c.isActive, - startDate: c.startDate, - endDate: c.endDate, - foodCategories: c.foodCategories, - foods: c.foods, - }; - } catch { - cart.coupon = undefined; - } - - if (coupon) { - // Basic validity checks (applyCoupon already does full validation) - const now = new Date(); - if (coupon.isActive === false) { - cart.coupon = undefined; - } - if (coupon.startDate && now < coupon.startDate) { - cart.coupon = undefined; - } - if (coupon.endDate && now > coupon.endDate) { - cart.coupon = undefined; - } - } - - if (cart.coupon) { - // 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 unitPrice = Number(item.price) || 0; - const unitDiscount = Math.min(Number(item.discount) || 0, unitPrice); - const itemPrice = unitPrice * item.quantity; - const itemDiscount = unitDiscount * item.quantity; - eligibleItemsTotal += itemPrice; - eligibleItemsDiscount += itemDiscount; - } - } - } else { - // No restrictions, apply to all items - eligibleItemsTotal = subTotal; - eligibleItemsDiscount = itemsDiscount; - } - - const priceAfterItemDiscount = Math.max(0, 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); - } - } - } - + const couponDiscount = await this.calculateCouponDiscount(cart, subTotal, itemsDiscount); 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 = Math.max(0, subTotal - cart.totalDiscount); - tax = (priceAfterDiscounts * Number(restaurant.vat)) / 100; - } + cart.tax = await this.calculateTax(cart.restaurantId, Math.max(0, subTotal - cart.totalDiscount)); + cart.deliveryFee = await this.calculateDeliveryFee(cart.deliveryMethodId); - 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 = Math.max(0, subTotal - cart.totalDiscount) + tax + cart.deliveryFee; + // total = subtotal – totalDiscount + tax + deliveryFee + cart.total = Math.max(0, subTotal - cart.totalDiscount) + cart.tax + cart.deliveryFee; cart.totalItems = totalItems; - cart.updatedAt = new Date().toISOString(); + cart.updatedAt = this.nowIso(); } /** @@ -865,4 +474,334 @@ export class CartService { totalPrice: this.calculateItemTotalPrice(itemPrice, itemDiscount, quantity), }; } + + private nowIso(): string { + return new Date().toISOString(); + } + + private async saveTouchedCart(cart: Cart): Promise { + cart.updatedAt = this.nowIso(); + await this.saveCart(cart); + return cart; + } + + private async recalculateAndSaveCart(cart: Cart): Promise { + await this.recalculateCartTotals(cart); + await this.saveCart(cart); + return cart; + } + + private getItemIndex(cart: Cart, foodId: string): number { + return cart.items.findIndex(item => item.foodId === foodId); + } + + private buildCartItemFromFood(food: Food, quantity: number, previous?: CartItem): CartItem { + const itemPrice = food.price || 0; + const itemDiscount = food.discount || 0; + return { + ...(previous ?? ({} as CartItem)), + foodId: food.id, + foodTitle: food.title, + quantity, + price: itemPrice, + discount: itemDiscount, + totalPrice: this.calculateItemTotalPrice(itemPrice, itemDiscount, quantity), + }; + } + + private addOrIncrementItem(cart: Cart, food: Food, quantityToAdd: number): void { + const index = this.getItemIndex(cart, food.id); + if (index < 0) { + cart.items.push(this.createCartItem(food, quantityToAdd)); + return; + } + + const existingItem = cart.items[index]; + const newQuantity = existingItem.quantity + quantityToAdd; + this.validateStock(food, newQuantity); + cart.items[index] = this.buildCartItemFromFood(food, newQuantity, existingItem); + } + + private removeItemOrFail(cart: Cart, foodId: string): void { + const itemIndex = this.getItemIndex(cart, foodId); + if (itemIndex < 0) { + throw new NotFoundException(`Item with food ID ${foodId} not found in cart`); + } + cart.items.splice(itemIndex, 1); + } + + private async decrementOrRemoveItem(cart: Cart, foodId: string): Promise { + const itemIndex = this.getItemIndex(cart, 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) { + cart.items.splice(itemIndex, 1); + return; + } + + const food = await this.getFoodOrFail(foodId); + cart.items[itemIndex] = this.buildCartItemFromFood(food, newQuantity, existingItem); + } + + private async getFoodOrFail(foodId: string): Promise { + const food = await this.em.findOne(Food, { id: foodId }); + if (!food) { + throw new NotFoundException(`Food with ID ${foodId} not found`); + } + return food; + } + + private async getUserOrFail(userId: string): Promise { + const user = await this.em.findOne(User, { id: userId }); + if (!user) { + throw new NotFoundException(`User with ID ${userId} not found`); + } + return user; + } + + private async getUserAddressOrFail(addressId: string): Promise { + const address = await this.em.findOne(UserAddress, { id: addressId }, { populate: ['user'] }); + if (!address) { + throw new NotFoundException(`Address with ID ${addressId} not found`); + } + return address; + } + + private requireDeliveryMethodId(cart: Cart, message: string): string { + if (!cart.deliveryMethodId) { + throw new BadRequestException(message); + } + return cart.deliveryMethodId; + } + + private async getDeliveryMethodForRestaurantOrFail(restaurantId: string, deliveryMethodId: string): Promise { + const deliveryMethod = await this.em.findOne(Delivery, { + id: deliveryMethodId, + restaurant: { id: restaurantId }, + }); + + if (!deliveryMethod) { + throw new NotFoundException(`Delivery method with ID ${deliveryMethodId} not found for restaurant ${restaurantId}`); + } + + return deliveryMethod; + } + + private assertDeliveryMethod( + actual: DeliveryMethodEnum, + expected: DeliveryMethodEnum, + message: string, + ): void { + if (actual !== expected) { + throw new BadRequestException(message); + } + } + + private async getEnabledPaymentMethodOrFail(restaurantId: string, paymentMethodId: string): Promise { + const paymentMethod = await this.em.findOne( + PaymentMethod, + { id: paymentMethodId, restaurant: { id: restaurantId } }, + { populate: ['restaurant'] }, + ); + if (!paymentMethod) { + throw new NotFoundException(`Payment method with ID ${paymentMethodId} not found for restaurant ${restaurantId}`); + } + if (!paymentMethod.enabled) { + throw new BadRequestException('Payment method is not enabled for this restaurant'); + } + return paymentMethod; + } + + private async getEnabledDeliveryMethodOrFail(restaurantId: string, deliveryMethodId: string): Promise { + const deliveryMethod = await this.em.findOne( + Delivery, + { id: deliveryMethodId, restaurant: { id: restaurantId } }, + { populate: ['restaurant'] }, + ); + if (!deliveryMethod) { + throw new NotFoundException(`Delivery method with ID ${deliveryMethodId} not found for restaurant ${restaurantId}`); + } + if (!deliveryMethod.enabled) { + throw new BadRequestException('Delivery method is not enabled for this restaurant'); + } + return deliveryMethod; + } + + private async assertCouponUsageLimit(userId: string, couponId: string, maxUsesPerUser?: number): Promise { + if (!maxUsesPerUser) return; + + 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, couponId], + ); + const userCouponUsageCount = result.rows?.[0]?.count ?? result[0]?.count ?? 0; + if (userCouponUsageCount >= maxUsesPerUser) { + throw new BadRequestException(`You have reached the maximum number of uses (${maxUsesPerUser}) for this coupon`); + } + } + + private async assertCouponMatchesCartIfRestricted( + cart: Cart, + foodCategories?: string[] | null, + foods?: string[] | null, + ): Promise { + const categoryRestriction = foodCategories?.filter(Boolean) ?? []; + const foodRestriction = foods?.filter(Boolean) ?? []; + const hasCategoryRestriction = categoryRestriction.length > 0; + const hasFoodRestriction = foodRestriction.length > 0; + if (!hasCategoryRestriction && !hasFoodRestriction) return; + + const foodMap = await this.getFoodsInCartWithCategories(cart); + + for (const item of cart.items) { + const food = foodMap.get(item.foodId); + if (!food) continue; + + const matchesCategory = hasCategoryRestriction && food.category && categoryRestriction.includes(food.category.id); + const matchesFood = hasFoodRestriction && foodRestriction.includes(food.id); + if (matchesCategory || matchesFood) return; + } + + throw new BadRequestException( + 'This coupon cannot be applied to the items in your cart. Please add items from the specified categories or foods.', + ); + } + + private async getFoodsInCartWithCategories(cart: Cart): Promise> { + const foodIds = cart.items.map(item => item.foodId); + if (foodIds.length === 0) return new Map(); + + const foods = await this.em.find(Food, { id: { $in: foodIds } }, { populate: ['category'] }); + return new Map(foods.map(f => [f.id, f])); + } + + private calculateItemsTotals(cart: Cart): { subTotal: number; itemsDiscount: number; totalItems: number } { + let subTotal = 0; + let itemsDiscount = 0; + let totalItems = 0; + + for (const item of cart.items) { + const unitPrice = Number(item.price) || 0; + const unitDiscount = Math.min(Number(item.discount) || 0, unitPrice); + const itemPrice = unitPrice * item.quantity; + const itemDiscount = unitDiscount * item.quantity; + subTotal += itemPrice; + itemsDiscount += itemDiscount; + totalItems += item.quantity; + item.totalPrice = itemPrice - itemDiscount; + } + + return { subTotal, itemsDiscount, totalItems }; + } + + private async calculateCouponDiscount(cart: Cart, subTotal: number, itemsDiscount: number): Promise { + if (!cart.coupon) return 0; + + const coupon = await this.getCouponRestrictionsOrClear(cart); + if (!cart.coupon || !coupon) return 0; + + const hasCategoryRestriction = (coupon.foodCategories?.length ?? 0) > 0; + const hasFoodRestriction = (coupon.foods?.length ?? 0) > 0; + + let eligibleItemsTotal = subTotal; + let eligibleItemsDiscount = itemsDiscount; + + if (hasCategoryRestriction || hasFoodRestriction) { + eligibleItemsTotal = 0; + eligibleItemsDiscount = 0; + + const foodMap = await this.getFoodsInCartWithCategories(cart); + + for (const item of cart.items) { + const food = foodMap.get(item.foodId); + if (!food) continue; + + const matchesCategory = + hasCategoryRestriction && food.category && (coupon.foodCategories ?? []).includes(food.category.id); + const matchesFood = hasFoodRestriction && (coupon.foods ?? []).includes(food.id); + + if (matchesCategory || matchesFood) { + const unitPrice = Number(item.price) || 0; + const unitDiscount = Math.min(Number(item.discount) || 0, unitPrice); + eligibleItemsTotal += unitPrice * item.quantity; + eligibleItemsDiscount += unitDiscount * item.quantity; + } + } + } + + const priceAfterItemDiscount = Math.max(0, eligibleItemsTotal - eligibleItemsDiscount); + + if (cart.coupon.type === CouponType.PERCENTAGE) { + let discount = (priceAfterItemDiscount * cart.coupon.value) / 100; + if (cart.coupon.maxDiscount && discount > cart.coupon.maxDiscount) { + discount = cart.coupon.maxDiscount; + } + return discount; + } + + return Math.min(cart.coupon.value, priceAfterItemDiscount); + } + + private async getCouponRestrictionsOrClear(cart: Cart): Promise<{ + isActive?: boolean; + startDate?: Date; + endDate?: Date; + foodCategories?: string[]; + foods?: string[]; + } | null> { + if (!cart.coupon) return null; + + try { + const c = await this.couponService.findById(cart.coupon.couponId); + const now = new Date(); + if (c.isActive === false) { + cart.coupon = undefined; + return null; + } + if (c.startDate && now < c.startDate) { + cart.coupon = undefined; + return null; + } + if (c.endDate && now > c.endDate) { + cart.coupon = undefined; + return null; + } + + return { + isActive: c.isActive, + startDate: c.startDate, + endDate: c.endDate, + foodCategories: c.foodCategories, + foods: c.foods, + }; + } catch { + cart.coupon = undefined; + return null; + } + } + + private async calculateTax(restaurantId: string, amountAfterDiscounts: number): Promise { + const restaurant = await this.em.findOne(Restaurant, { id: restaurantId }); + const vat = restaurant?.vat ? Number(restaurant.vat) : 0; + if (!vat || vat <= 0) return 0; + return (Math.max(0, amountAfterDiscounts) * vat) / 100; + } + + private async calculateDeliveryFee(deliveryMethodId?: string): Promise { + if (!deliveryMethodId) return 0; + const deliveryMethod = await this.em.findOne(Delivery, { id: deliveryMethodId }); + if (!deliveryMethod || !deliveryMethod.enabled) return 0; + return Number(deliveryMethod.deliveryFee) || 0; + } }