refactor cart service
This commit is contained in:
@@ -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<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 {
|
||||
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<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;
|
||||
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<Cart> {
|
||||
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<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');
|
||||
}
|
||||
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<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');
|
||||
}
|
||||
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<Cart> {
|
||||
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<Cart> {
|
||||
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<void> {
|
||||
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> {
|
||||
cart.updatedAt = this.nowIso();
|
||||
await this.saveCart(cart);
|
||||
return cart;
|
||||
}
|
||||
|
||||
private async recalculateAndSaveCart(cart: Cart): Promise<Cart> {
|
||||
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<void> {
|
||||
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<Food> {
|
||||
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<User> {
|
||||
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<UserAddress> {
|
||||
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<Delivery> {
|
||||
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<PaymentMethod> {
|
||||
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<Delivery> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<Map<string, Food>> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
if (!deliveryMethodId) return 0;
|
||||
const deliveryMethod = await this.em.findOne(Delivery, { id: deliveryMethodId });
|
||||
if (!deliveryMethod || !deliveryMethod.enabled) return 0;
|
||||
return Number(deliveryMethod.deliveryFee) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user