refactor cart service
This commit is contained in:
@@ -23,7 +23,7 @@ import { CouponType } from 'src/modules/coupons/interface/coupon';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CartService {
|
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';
|
private readonly CART_KEY_PREFIX = 'cart';
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -48,7 +48,7 @@ export class CartService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create new cart
|
// Create new cart
|
||||||
const now = new Date().toISOString();
|
const now = this.nowIso();
|
||||||
const cart: Cart = {
|
const cart: Cart = {
|
||||||
userId,
|
userId,
|
||||||
restaurantId,
|
restaurantId,
|
||||||
@@ -88,29 +88,8 @@ export class CartService {
|
|||||||
const cart = await this.getOrCreateCart(userId, restaurantId);
|
const cart = await this.getOrCreateCart(userId, restaurantId);
|
||||||
const food = await this.validateAndGetFood(foodId, restaurantId, quantity);
|
const food = await this.validateAndGetFood(foodId, restaurantId, quantity);
|
||||||
|
|
||||||
const existingItemIndex = cart.items.findIndex(item => item.foodId === foodId);
|
this.addOrIncrementItem(cart, food, quantity);
|
||||||
|
return this.recalculateAndSaveCart(cart);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -118,39 +97,8 @@ export class CartService {
|
|||||||
*/
|
*/
|
||||||
async decrementItem(userId: string, restaurantId: string, foodId: string): Promise<Cart> {
|
async decrementItem(userId: string, restaurantId: string, foodId: string): Promise<Cart> {
|
||||||
const cart = await this.findOneOrFail(userId, restaurantId);
|
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||||
|
await this.decrementOrRemoveItem(cart, foodId);
|
||||||
const itemIndex = cart.items.findIndex(item => item.foodId === foodId);
|
return this.recalculateAndSaveCart(cart);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -161,30 +109,10 @@ export class CartService {
|
|||||||
|
|
||||||
for (const addItemDto of bulkAddItemsDto.items) {
|
for (const addItemDto of bulkAddItemsDto.items) {
|
||||||
const food = await this.validateAndGetFood(addItemDto.foodId, restaurantId, addItemDto.quantity);
|
const food = await this.validateAndGetFood(addItemDto.foodId, restaurantId, addItemDto.quantity);
|
||||||
const existingItemIndex = cart.items.findIndex(item => item.foodId === addItemDto.foodId);
|
this.addOrIncrementItem(cart, food, addItemDto.quantity);
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.recalculateCartTotals(cart);
|
return this.recalculateAndSaveCart(cart);
|
||||||
await this.saveCart(cart);
|
|
||||||
|
|
||||||
return cart;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -192,20 +120,8 @@ export class CartService {
|
|||||||
*/
|
*/
|
||||||
async removeItem(userId: string, restaurantId: string, foodId: string): Promise<Cart> {
|
async removeItem(userId: string, restaurantId: string, foodId: string): Promise<Cart> {
|
||||||
const cart = await this.findOneOrFail(userId, restaurantId);
|
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||||
|
this.removeItemOrFail(cart, foodId);
|
||||||
const itemIndex = cart.items.findIndex(item => item.foodId === foodId);
|
return this.recalculateAndSaveCart(cart);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -215,10 +131,7 @@ export class CartService {
|
|||||||
const cart = await this.findOneOrFail(userId, restaurantId);
|
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||||
|
|
||||||
const orderAmount = cart.subTotal - cart.itemsDiscount;
|
const orderAmount = cart.subTotal - cart.itemsDiscount;
|
||||||
const user = await this.em.findOne(User, { id: userId });
|
const user = await this.getUserOrFail(userId);
|
||||||
if (!user) {
|
|
||||||
throw new NotFoundException(`User with ID ${userId} not found`);
|
|
||||||
}
|
|
||||||
// check if coupon is valid and belong to the restaurant
|
// check if coupon is valid and belong to the restaurant
|
||||||
const { valid, coupon, message } = await this.couponService.validateCoupon(
|
const { valid, coupon, message } = await this.couponService.validateCoupon(
|
||||||
applyCouponDto.code,
|
applyCouponDto.code,
|
||||||
@@ -236,61 +149,8 @@ export class CartService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check maxUsesPerUser limit by counting how many orders this user has with this coupon
|
// Check maxUsesPerUser limit by counting how many orders this user has with this coupon
|
||||||
if (coupon.maxUsesPerUser) {
|
await this.assertCouponUsageLimit(userId, coupon.id, coupon.maxUsesPerUser);
|
||||||
// Use native query for JSONB field access with proper parameter binding
|
await this.assertCouponMatchesCartIfRestricted(cart, coupon.foodCategories, coupon.foods);
|
||||||
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.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const couponDetail: OrderCouponDetail = {
|
const couponDetail: OrderCouponDetail = {
|
||||||
couponId: coupon.id,
|
couponId: coupon.id,
|
||||||
@@ -302,12 +162,7 @@ export class CartService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
cart.coupon = couponDetail;
|
cart.coupon = couponDetail;
|
||||||
|
return this.recalculateAndSaveCart(cart);
|
||||||
// Recalculate cart totals
|
|
||||||
await this.recalculateCartTotals(cart);
|
|
||||||
await this.saveCart(cart);
|
|
||||||
|
|
||||||
return cart;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -317,12 +172,7 @@ export class CartService {
|
|||||||
const cart = await this.findOneOrFail(userId, restaurantId);
|
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||||
|
|
||||||
cart.coupon = undefined;
|
cart.coupon = undefined;
|
||||||
|
return this.recalculateAndSaveCart(cart);
|
||||||
// Recalculate cart totals
|
|
||||||
await this.recalculateCartTotals(cart);
|
|
||||||
await this.saveCart(cart);
|
|
||||||
|
|
||||||
return cart;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -331,31 +181,22 @@ export class CartService {
|
|||||||
async setAddress(userId: string, restaurantId: string, setAddressDto: SetAddressDto): Promise<Cart> {
|
async setAddress(userId: string, restaurantId: string, setAddressDto: SetAddressDto): Promise<Cart> {
|
||||||
const cart = await this.findOneOrFail(userId, restaurantId);
|
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||||
|
|
||||||
if (!cart.deliveryMethodId) throw new BadRequestException('Delivery method must be set before setting address');
|
const deliveryMethodId = this.requireDeliveryMethodId(
|
||||||
const deliveryMethod = await this.em.findOne(Delivery, {
|
cart,
|
||||||
id: cart.deliveryMethodId,
|
'Delivery method must be set before setting address',
|
||||||
restaurant: { id: restaurantId },
|
);
|
||||||
});
|
const deliveryMethod = await this.getDeliveryMethodForRestaurantOrFail(restaurantId, deliveryMethodId);
|
||||||
if (!deliveryMethod) {
|
this.assertDeliveryMethod(deliveryMethod.method, DeliveryMethodEnum.DeliveryCourier, 'Delivery method must be DeliveryCourier');
|
||||||
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
|
// Find and validate address belongs to user
|
||||||
const address = await this.em.findOne(UserAddress, { id: setAddressDto.addressId }, { populate: ['user'] });
|
const address = await this.getUserAddressOrFail(setAddressDto.addressId);
|
||||||
if (!address) {
|
|
||||||
throw new NotFoundException(`Address with ID ${setAddressDto.addressId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify address belongs to the user
|
// Verify address belongs to the user
|
||||||
if (address.user.id !== userId) {
|
if (address.user.id !== userId) {
|
||||||
throw new BadRequestException('Address does not belong to the current user');
|
throw new BadRequestException('Address does not belong to the current user');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DeliveryCourier);
|
||||||
cart.userAddress = {
|
cart.userAddress = {
|
||||||
address: address.address,
|
address: address.address,
|
||||||
latitude: address.latitude,
|
latitude: address.latitude,
|
||||||
@@ -366,14 +207,7 @@ export class CartService {
|
|||||||
fullName: `${address.user.firstName || ''} ${address.user.lastName || ''}`.trim(),
|
fullName: `${address.user.firstName || ''} ${address.user.lastName || ''}`.trim(),
|
||||||
phone: address.user.phone,
|
phone: address.user.phone,
|
||||||
};
|
};
|
||||||
// DeliveryCourier implies: no car + no dine-in table
|
return this.saveTouchedCart(cart);
|
||||||
cart.carAddress = null;
|
|
||||||
cart.tableNumber = undefined;
|
|
||||||
cart.updatedAt = new Date().toISOString();
|
|
||||||
|
|
||||||
await this.saveCart(cart);
|
|
||||||
|
|
||||||
return cart;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -386,34 +220,10 @@ export class CartService {
|
|||||||
): Promise<Cart> {
|
): Promise<Cart> {
|
||||||
const cart = await this.findOneOrFail(userId, restaurantId);
|
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||||
|
|
||||||
// Find and validate payment method belongs to restaurant
|
await this.getEnabledPaymentMethodOrFail(restaurantId, setPaymentMethodDto.paymentMethodId);
|
||||||
const paymentMethod = await this.em.findOne(
|
|
||||||
PaymentMethod,
|
|
||||||
{
|
|
||||||
id: setPaymentMethodDto.paymentMethodId,
|
|
||||||
restaurant: { id: restaurantId },
|
|
||||||
},
|
|
||||||
{ populate: ['restaurant'] },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!paymentMethod) {
|
|
||||||
throw new NotFoundException(
|
|
||||||
`Payment method with ID ${setPaymentMethodDto.paymentMethodId} not found for restaurant ${restaurantId}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify payment method is enabled
|
|
||||||
if (!paymentMethod.enabled) {
|
|
||||||
throw new BadRequestException('Payment method is not enabled for this restaurant');
|
|
||||||
}
|
|
||||||
|
|
||||||
cart.paymentMethodId = setPaymentMethodDto.paymentMethodId;
|
cart.paymentMethodId = setPaymentMethodDto.paymentMethodId;
|
||||||
|
return this.recalculateAndSaveCart(cart);
|
||||||
// Recalculate cart totals
|
|
||||||
await this.recalculateCartTotals(cart);
|
|
||||||
await this.saveCart(cart);
|
|
||||||
|
|
||||||
return cart;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -426,35 +236,12 @@ export class CartService {
|
|||||||
): Promise<Cart> {
|
): Promise<Cart> {
|
||||||
const cart = await this.findOneOrFail(userId, restaurantId);
|
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||||
|
|
||||||
// Find and validate delivery method belongs to restaurant
|
const deliveryMethod = await this.getEnabledDeliveryMethodOrFail(restaurantId, setDeliveryMethodDto.deliveryMethodId);
|
||||||
const deliveryMethod = await this.em.findOne(
|
|
||||||
Delivery,
|
|
||||||
{
|
|
||||||
id: setDeliveryMethodDto.deliveryMethodId,
|
|
||||||
restaurant: { id: restaurantId },
|
|
||||||
},
|
|
||||||
{ populate: ['restaurant'] },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!deliveryMethod) {
|
|
||||||
throw new NotFoundException(
|
|
||||||
`Delivery method with ID ${setDeliveryMethodDto.deliveryMethodId} not found for restaurant ${restaurantId}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify delivery method is enabled
|
|
||||||
if (!deliveryMethod.enabled) {
|
|
||||||
throw new BadRequestException('Delivery method is not enabled for this restaurant');
|
|
||||||
}
|
|
||||||
|
|
||||||
cart.deliveryMethodId = setDeliveryMethodDto.deliveryMethodId;
|
cart.deliveryMethodId = setDeliveryMethodDto.deliveryMethodId;
|
||||||
this.clearFieldsIncompatibleWithDeliveryMethod(cart, deliveryMethod.method);
|
this.clearFieldsIncompatibleWithDeliveryMethod(cart, deliveryMethod.method);
|
||||||
|
|
||||||
// Recalculate cart totals (this will update deliveryFee based on delivery method)
|
return this.recalculateAndSaveCart(cart);
|
||||||
await this.recalculateCartTotals(cart);
|
|
||||||
await this.saveCart(cart);
|
|
||||||
|
|
||||||
return cart;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -464,11 +251,7 @@ export class CartService {
|
|||||||
const cart = await this.findOneOrFail(userId, restaurantId);
|
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||||
|
|
||||||
cart.description = setDescriptionDto.description;
|
cart.description = setDescriptionDto.description;
|
||||||
cart.updatedAt = new Date().toISOString();
|
return this.saveTouchedCart(cart);
|
||||||
|
|
||||||
await this.saveCart(cart);
|
|
||||||
|
|
||||||
return cart;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -477,31 +260,16 @@ export class CartService {
|
|||||||
async setTableNumber(userId: string, restaurantId: string, setTableNumberDto: SetTableNumberDto): Promise<Cart> {
|
async setTableNumber(userId: string, restaurantId: string, setTableNumberDto: SetTableNumberDto): Promise<Cart> {
|
||||||
const cart = await this.findOneOrFail(userId, restaurantId);
|
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||||
|
|
||||||
if (!cart.deliveryMethodId) {
|
const deliveryMethodId = this.requireDeliveryMethodId(
|
||||||
throw new BadRequestException('Delivery method must be set before setting table number');
|
cart,
|
||||||
}
|
'Delivery method must be set before setting table number',
|
||||||
const deliveryMethod = await this.em.findOne(Delivery, {
|
);
|
||||||
id: cart.deliveryMethodId,
|
const deliveryMethod = await this.getDeliveryMethodForRestaurantOrFail(restaurantId, deliveryMethodId);
|
||||||
restaurant: { id: restaurantId },
|
this.assertDeliveryMethod(deliveryMethod.method, DeliveryMethodEnum.DineIn, 'Delivery method must be DineIn');
|
||||||
});
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DineIn);
|
||||||
cart.tableNumber = setTableNumberDto.tableNumber;
|
cart.tableNumber = setTableNumberDto.tableNumber;
|
||||||
// DineIn implies: no delivery addresses
|
return this.saveTouchedCart(cart);
|
||||||
cart.userAddress = null;
|
|
||||||
cart.carAddress = null;
|
|
||||||
cart.updatedAt = new Date().toISOString();
|
|
||||||
|
|
||||||
await this.saveCart(cart);
|
|
||||||
|
|
||||||
return cart;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -510,202 +278,43 @@ export class CartService {
|
|||||||
async setCarDelivery(userId: string, restaurantId: string, setCarDeliveryDto: SetCarDeliveryDto): Promise<Cart> {
|
async setCarDelivery(userId: string, restaurantId: string, setCarDeliveryDto: SetCarDeliveryDto): Promise<Cart> {
|
||||||
const cart = await this.findOneOrFail(userId, restaurantId);
|
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||||
|
|
||||||
if (!cart.deliveryMethodId)
|
const deliveryMethodId = this.requireDeliveryMethodId(
|
||||||
throw new BadRequestException('Delivery method must be set before setting car delivery');
|
cart,
|
||||||
const deliveryMethod = await this.em.findOne(Delivery, {
|
'Delivery method must be set before setting car delivery',
|
||||||
id: cart.deliveryMethodId,
|
);
|
||||||
restaurant: { id: restaurantId },
|
const deliveryMethod = await this.getDeliveryMethodForRestaurantOrFail(restaurantId, deliveryMethodId);
|
||||||
});
|
this.assertDeliveryMethod(deliveryMethod.method, DeliveryMethodEnum.DeliveryCar, 'Delivery method must be DeliveryCar');
|
||||||
|
const user = await this.getUserOrFail(userId);
|
||||||
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`);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DeliveryCar);
|
||||||
cart.carAddress = {
|
cart.carAddress = {
|
||||||
carModel: setCarDeliveryDto.carModel,
|
carModel: setCarDeliveryDto.carModel,
|
||||||
carColor: setCarDeliveryDto.carColor,
|
carColor: setCarDeliveryDto.carColor,
|
||||||
plateNumber: setCarDeliveryDto.plateNumber,
|
plateNumber: setCarDeliveryDto.plateNumber,
|
||||||
phone: user.phone,
|
phone: user.phone,
|
||||||
};
|
};
|
||||||
// DeliveryCar implies: no courier address + no dine-in table
|
return this.saveTouchedCart(cart);
|
||||||
cart.userAddress = null;
|
|
||||||
cart.tableNumber = undefined;
|
|
||||||
cart.updatedAt = new Date().toISOString();
|
|
||||||
|
|
||||||
await this.saveCart(cart);
|
|
||||||
|
|
||||||
return cart;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recalculate cart totals (including coupon discount and tax)
|
* Recalculate cart totals (including coupon discount and tax)
|
||||||
*/
|
*/
|
||||||
private async recalculateCartTotals(cart: Cart): Promise<void> {
|
private async recalculateCartTotals(cart: Cart): Promise<void> {
|
||||||
let subTotal = 0;
|
const { subTotal, itemsDiscount, totalItems } = this.calculateItemsTotals(cart);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
cart.subTotal = subTotal;
|
cart.subTotal = subTotal;
|
||||||
cart.itemsDiscount = itemsDiscount;
|
cart.itemsDiscount = itemsDiscount;
|
||||||
|
|
||||||
// Calculate coupon discount
|
const couponDiscount = await this.calculateCouponDiscount(cart, subTotal, itemsDiscount);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cart.couponDiscount = couponDiscount;
|
cart.couponDiscount = couponDiscount;
|
||||||
// Calculate total discount: totalDiscount = couponDiscount + itemsDiscount
|
|
||||||
cart.totalDiscount = couponDiscount + itemsDiscount;
|
cart.totalDiscount = couponDiscount + itemsDiscount;
|
||||||
|
|
||||||
// Calculate tax if restaurant has VAT rate > 0
|
cart.tax = await this.calculateTax(cart.restaurantId, Math.max(0, subTotal - cart.totalDiscount));
|
||||||
let tax = 0;
|
cart.deliveryFee = await this.calculateDeliveryFee(cart.deliveryMethodId);
|
||||||
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 = tax;
|
// total = subtotal – totalDiscount + tax + deliveryFee
|
||||||
|
cart.total = Math.max(0, subTotal - cart.totalDiscount) + cart.tax + cart.deliveryFee;
|
||||||
// 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;
|
|
||||||
cart.totalItems = totalItems;
|
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),
|
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