refactor cart service and DTOs to improve item handling and delivery method compatibility

This commit is contained in:
2025-12-17 09:21:46 +03:30
parent 87534e8c6f
commit 25a8391ced
4 changed files with 186 additions and 69 deletions
+3 -2
View File
@@ -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;
+3 -2
View File
@@ -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;
}
+177 -62
View File
@@ -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),
};
}
}
@@ -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,
};
}