diff --git a/src/modules/cart/dto/bulk-add-items.dto.ts b/src/modules/cart/dto/bulk-add-items.dto.ts index f54ba78..71ac392 100644 --- a/src/modules/cart/dto/bulk-add-items.dto.ts +++ b/src/modules/cart/dto/bulk-add-items.dto.ts @@ -1,11 +1,12 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsArray, ValidateNested, ArrayMinSize, IsNumber, Min, IsString } from 'class-validator'; +import { IsNotEmpty, IsArray, ValidateNested, ArrayMinSize, IsInt, Min, IsString } from 'class-validator'; import { Type } from 'class-transformer'; class AddItemToCartDto { @ApiProperty({ description: 'Quantity of the food item', example: 1, minimum: 1 }) @IsNotEmpty() - @IsNumber() + @Type(() => Number) + @IsInt() @Min(1) quantity!: number; diff --git a/src/modules/cart/dto/create-cart.dto.ts b/src/modules/cart/dto/create-cart.dto.ts index 550ff1f..ec5763b 100644 --- a/src/modules/cart/dto/create-cart.dto.ts +++ b/src/modules/cart/dto/create-cart.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsString, IsArray, ValidateNested, IsNumber, Min } from 'class-validator'; +import { IsNotEmpty, IsString, IsArray, ValidateNested, IsInt, Min } from 'class-validator'; import { Type } from 'class-transformer'; export class CartItemDto { @@ -10,7 +10,8 @@ export class CartItemDto { @ApiProperty({ description: 'Quantity of the food item', example: 2, minimum: 1 }) @IsNotEmpty() - @IsNumber() + @Type(() => Number) + @IsInt() @Min(1) quantity!: number; } diff --git a/src/modules/cart/providers/cart.service.ts b/src/modules/cart/providers/cart.service.ts index fb04fc3..d91fd1d 100644 --- a/src/modules/cart/providers/cart.service.ts +++ b/src/modules/cart/providers/cart.service.ts @@ -97,8 +97,11 @@ export class CartService { 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, newQuantity), + totalPrice: this.calculateItemTotalPrice(food.price || 0, food.discount || 0, newQuantity), }; } else { cart.items.push(this.createCartItem(food, quantity)); @@ -135,8 +138,11 @@ export class CartService { cart.items[itemIndex] = { ...existingItem, + foodTitle: food.title, + price: food.price || 0, + discount: food.discount || 0, quantity: newQuantity, - totalPrice: this.calculateItemTotalPrice(food, newQuantity), + totalPrice: this.calculateItemTotalPrice(food.price || 0, food.discount || 0, newQuantity), }; } @@ -164,8 +170,11 @@ export class CartService { 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, newQuantity), + totalPrice: this.calculateItemTotalPrice(food.price || 0, food.discount || 0, newQuantity), }; } else { cart.items.push(this.createCartItem(food, addItemDto.quantity)); @@ -323,6 +332,19 @@ export class CartService { 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'); + } + // Find and validate address belongs to user const address = await this.em.findOne(UserAddress, { id: setAddressDto.addressId }, { populate: ['user'] }); if (!address) { @@ -341,9 +363,12 @@ export class CartService { city: address.city, province: address.province || '', postalCode: address.postalCode || undefined, - fullName: address.user.firstName + ' ' + address.user.lastName || '', + 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); @@ -423,6 +448,7 @@ export class CartService { } cart.deliveryMethodId = setDeliveryMethodDto.deliveryMethodId; + this.clearFieldsIncompatibleWithDeliveryMethod(cart, deliveryMethod.method); // Recalculate cart totals (this will update deliveryFee based on delivery method) await this.recalculateCartTotals(cart); @@ -468,6 +494,9 @@ export class CartService { } cart.tableNumber = setTableNumberDto.tableNumber; + // DineIn implies: no delivery addresses + cart.userAddress = null; + cart.carAddress = null; cart.updatedAt = new Date().toISOString(); await this.saveCart(cart); @@ -507,6 +536,9 @@ export class CartService { 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); @@ -523,11 +555,16 @@ export class CartService { let totalItems = 0; for (const item of cart.items) { - const itemPrice = item.price * item.quantity; - const itemDiscount = item.discount * item.quantity; + 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; } cart.subTotal = subTotal; @@ -537,58 +574,106 @@ export class CartService { 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; - } + // 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[]; } - } else { - // No restrictions, apply to all items - eligibleItemsTotal = subTotal; - eligibleItemsDiscount = itemsDiscount; + | 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; } - 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; + 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); } - couponDiscount = discount; - } else { - // FIXED amount discount - couponDiscount = Math.min(cart.coupon.value, priceAfterItemDiscount); } } @@ -601,7 +686,7 @@ export class CartService { 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; + const priceAfterDiscounts = Math.max(0, subTotal - cart.totalDiscount); tax = (priceAfterDiscounts * Number(restaurant.vat)) / 100; } @@ -618,11 +703,41 @@ export class CartService { cart.deliveryFee = deliveryFee; // Calculate total: total = subtotal – totalDiscount + tax + deliveryFee - cart.total = subTotal - cart.totalDiscount + tax + cart.deliveryFee; + cart.total = Math.max(0, subTotal - cart.totalDiscount) + tax + cart.deliveryFee; cart.totalItems = totalItems; cart.updatedAt = new Date().toISOString(); } + /** + * Clears cart fields that are not applicable for a given delivery method. + */ + private clearFieldsIncompatibleWithDeliveryMethod(cart: Cart, method: DeliveryMethodEnum): void { + if (method === DeliveryMethodEnum.DineIn) { + cart.userAddress = null; + cart.carAddress = null; + return; + } + + if (method === DeliveryMethodEnum.CustomerPickup) { + cart.userAddress = null; + cart.carAddress = null; + cart.tableNumber = undefined; + return; + } + + if (method === DeliveryMethodEnum.DeliveryCourier) { + cart.carAddress = null; + cart.tableNumber = undefined; + return; + } + + if (method === DeliveryMethodEnum.DeliveryCar) { + cart.userAddress = null; + cart.tableNumber = undefined; + return; + } + } + /** * Type guard to check if an object is a Cart */ @@ -726,11 +841,11 @@ export class CartService { /** * Calculate total price for a cart item */ - private calculateItemTotalPrice(food: Food, quantity: number): number { - const itemPrice = food.price || 0; - const itemDiscount = food.discount || 0; - const itemTotalPrice = itemPrice * quantity; - const itemTotalDiscount = itemDiscount * quantity; + private calculateItemTotalPrice(unitPrice: number, unitDiscount: number, quantity: number): number { + const safeUnitPrice = Number(unitPrice) || 0; + const safeUnitDiscount = Math.min(Number(unitDiscount) || 0, safeUnitPrice); + const itemTotalPrice = safeUnitPrice * quantity; + const itemTotalDiscount = safeUnitDiscount * quantity; return itemTotalPrice - itemTotalDiscount; } @@ -747,7 +862,7 @@ export class CartService { quantity, price: itemPrice, discount: itemDiscount, - totalPrice: this.calculateItemTotalPrice(food, quantity), + totalPrice: this.calculateItemTotalPrice(itemPrice, itemDiscount, quantity), }; } } diff --git a/src/modules/orders/providers/orders.service.ts b/src/modules/orders/providers/orders.service.ts index 1f33887..d350796 100644 --- a/src/modules/orders/providers/orders.service.ts +++ b/src/modules/orders/providers/orders.service.ts @@ -31,7 +31,7 @@ export class OrdersService { private readonly orderRepository: OrderRepository, private readonly eventEmitter2: EventEmitter2, // private readonly paymentGatewayService: PaymentGatewayService, - ) {} + ) { } async checkout(userId: string, restaurantId: string) { const cart = await this.cartService.findOneOrFail(userId, restaurantId); @@ -251,8 +251,8 @@ export class OrdersService { restaurant, delivery, paymentMethod, - userAddress: cart?.userAddress ?? null, - carAddress: cart?.carAddress ?? null, + userAddress: delivery.method === DeliveryMethodEnum.DeliveryCourier ? (cart?.userAddress ?? null) : null, + carAddress: delivery.method === DeliveryMethodEnum.DeliveryCar ? (cart?.carAddress ?? null) : null, orderItemsData, }; }