refactor cart service

This commit is contained in:
2025-12-17 09:47:59 +03:30
parent 464d8aaa77
commit a2ef48e1c9
+383 -444
View File
@@ -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;
}
}