Files
dmenu-api/src/modules/cart/providers/cart.service.ts
T
2025-12-07 11:49:29 +03:30

779 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { Food } from '../../foods/entities/food.entity';
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
import { EntityManager } from '@mikro-orm/postgresql';
import { CacheService } from '../../utils/cache.service';
import { AddItemToCartDto } from '../dto/add-item.dto';
import { BulkAddItemsToCartDto } from '../dto/bulk-add-items.dto';
import { UpdateItemQuantityDto } from '../dto/update-item.dto';
import { ApplyCouponDto } from '../dto/apply-coupon.dto';
import { SetAddressDto } from '../dto/set-address.dto';
import { SetPaymentMethodDto } from '../dto/set-payment-method.dto';
import { SetDeliveryMethodDto } from '../dto/set-delivery-method.dto';
import { SetDescriptionDto } from '../dto/set-description.dto';
import { UserAddress } from '../../users/entities/user-address.entity';
import { User } from '../../users/entities/user.entity';
import { PaymentMethod } from '../../payments/entities/payment-method.entity';
import { Delivery } from '../../delivery/entities/delivery.entity';
import { Cart, CartItem } from '../interfaces/cart.interface';
import { CouponService } from 'src/modules/coupons/providers/coupon.service';
import { OrderCouponDetail } from 'src/modules/orders/interface/order-status';
import { CouponType } from 'src/modules/coupons/interface/coupon';
import { Order } from '../../orders/entities/order.entity';
@Injectable()
export class CartService {
private readonly CART_TTL = 1200; // 20 minutes in seconds
private readonly CART_KEY_PREFIX = 'cart';
constructor(
private readonly em: EntityManager,
private readonly cacheService: CacheService,
private readonly couponService: CouponService,
) {}
/**
* Get or create cart for user and restaurant
*/
async getOrCreateCart(userId: string, restaurantId: string): Promise<Cart> {
const existingCart = await this.getCartByRestaurant(userId, restaurantId);
if (existingCart) {
return existingCart;
}
// Find restaurant
const restaurant = await this.em.findOne(Restaurant, { id: restaurantId });
if (!restaurant) {
throw new NotFoundException('Restaurant not found');
}
// Create new cart
const now = new Date().toISOString();
const cart: Cart = {
userId,
restaurantId,
restaurantName: restaurant.name,
items: [],
deliveryFee: 0,
subTotal: 0,
itemsDiscount: 0,
couponDiscount: 0,
totalDiscount: 0,
tax: 0,
total: 0,
totalItems: 0,
createdAt: now,
updatedAt: now,
};
await this.saveCart(cart);
return cart;
}
/**
* Find cart by user and restaurant
*/
async findOneOrFail(userId: string, restaurantId: string): Promise<Cart> {
const cart = await this.getCartByRestaurant(userId, restaurantId);
if (!cart) {
throw new NotFoundException('Cart not found');
}
return cart;
}
async findOne2(userId: string, restaurantId: string): Promise<Cart | null> {
const cart = await this.getCartByRestaurant(userId, restaurantId);
return cart;
}
/**
* Add item to cart (increment quantity if item exists, otherwise create new)
*/
async addItem(userId: string, restaurantId: string, foodId: string, addItemDto: AddItemToCartDto): Promise<Cart> {
const cart = await this.getOrCreateCart(userId, restaurantId);
// Find food
const food = await this.em.findOne(Food, { id: foodId }, { populate: ['restaurant'] });
if (!food) {
throw new NotFoundException(`Food with ID ${foodId} not found`);
}
// Check if food belongs to the restaurant
if (food.restaurant.id !== restaurantId) {
throw new BadRequestException(`Food ${food.title || food.id} does not belong to restaurant ${restaurantId}`);
}
// Check stock availability
if (food.stock < addItemDto.quantity) {
throw new BadRequestException(`Insufficient stock for food ${food.title}. Available: ${food.stock}`);
}
// Check if item already exists in cart
const existingItemIndex = cart.items.findIndex(item => item.foodId === foodId);
if (existingItemIndex >= 0) {
// Update existing item quantity
const existingItem = cart.items[existingItemIndex];
const newQuantity = existingItem.quantity + addItemDto.quantity;
// Check stock for new total quantity
if (food.stock < newQuantity) {
throw new BadRequestException(`Insufficient stock for food ${food.title}. Available: ${food.stock}`);
}
const itemPrice = food.price || 0;
const itemDiscount = food.discount || 0;
const itemTotalPrice = itemPrice * newQuantity;
const itemTotalDiscount = itemDiscount * newQuantity;
const itemFinalPrice = itemTotalPrice - itemTotalDiscount;
cart.items[existingItemIndex] = {
...existingItem,
quantity: newQuantity,
totalPrice: itemFinalPrice,
};
} else {
// Create new item
const itemPrice = food.price || 0;
const itemDiscount = food.discount || 0;
const itemTotalPrice = itemPrice * addItemDto.quantity;
const itemTotalDiscount = itemDiscount * addItemDto.quantity;
const itemFinalPrice = itemTotalPrice - itemTotalDiscount;
const cartItem: CartItem = {
foodId: food.id,
foodTitle: food.title,
quantity: addItemDto.quantity,
price: itemPrice,
discount: itemDiscount,
totalPrice: itemFinalPrice,
};
cart.items.push(cartItem);
}
// Recalculate cart totals
await this.recalculateCartTotals(cart);
await this.saveCart(cart);
return cart;
}
/**
* Increment item quantity in cart by 1
*/
async incrementItem(userId: string, restaurantId: string, foodId: string): Promise<Cart> {
return this.addItem(userId, restaurantId, foodId, { foodId, quantity: 1 });
}
/**
* Decrement item quantity in cart by 1 (removes item if quantity reaches 0)
*/
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 {
// Update item quantity and recalculate price
const food = await this.em.findOne(Food, { id: foodId });
if (!food) {
throw new NotFoundException(`Food with ID ${foodId} not found`);
}
const itemPrice = food.price || 0;
const itemDiscount = food.discount || 0;
const itemTotalPrice = itemPrice * newQuantity;
const itemTotalDiscount = itemDiscount * newQuantity;
const itemFinalPrice = itemTotalPrice - itemTotalDiscount;
cart.items[itemIndex] = {
...existingItem,
quantity: newQuantity,
totalPrice: itemFinalPrice,
};
}
// Recalculate cart totals
await this.recalculateCartTotals(cart);
await this.saveCart(cart);
return cart;
}
/**
* Bulk add items to cart (increments quantity if items exist)
*/
async bulkAddItems(userId: string, restaurantId: string, bulkAddItemsDto: BulkAddItemsToCartDto): Promise<Cart> {
const cart = await this.getOrCreateCart(userId, restaurantId);
// Process each item
for (const addItemDto of bulkAddItemsDto.items) {
// Find food
const food = await this.em.findOne(Food, { id: addItemDto.foodId }, { populate: ['restaurant'] });
if (!food) {
throw new NotFoundException(`Food with ID ${addItemDto.foodId} not found`);
}
// Check if food belongs to the restaurant
if (food.restaurant.id !== restaurantId) {
throw new BadRequestException(`Food ${food.title || food.id} does not belong to restaurant ${restaurantId}`);
}
// Check stock availability
if (food.stock < addItemDto.quantity) {
throw new BadRequestException(`Insufficient stock for food ${food.title}. Available: ${food.stock}`);
}
// Check if item already exists in cart
const existingItemIndex = cart.items.findIndex(item => item.foodId === addItemDto.foodId);
if (existingItemIndex >= 0) {
// Update existing item quantity
const existingItem = cart.items[existingItemIndex];
const newQuantity = existingItem.quantity + addItemDto.quantity;
// Check stock for new total quantity
if (food.stock < newQuantity) {
throw new BadRequestException(`Insufficient stock for food ${food.title}. Available: ${food.stock}`);
}
const itemPrice = food.price || 0;
const itemDiscount = food.discount || 0;
const itemTotalPrice = itemPrice * newQuantity;
const itemTotalDiscount = itemDiscount * newQuantity;
const itemFinalPrice = itemTotalPrice - itemTotalDiscount;
cart.items[existingItemIndex] = {
...existingItem,
quantity: newQuantity,
totalPrice: itemFinalPrice,
};
} else {
// Create new item
const itemPrice = food.price || 0;
const itemDiscount = food.discount || 0;
const itemTotalPrice = itemPrice * addItemDto.quantity;
const itemTotalDiscount = itemDiscount * addItemDto.quantity;
const itemFinalPrice = itemTotalPrice - itemTotalDiscount;
const cartItem: CartItem = {
foodId: food.id,
foodTitle: food.title,
quantity: addItemDto.quantity,
price: itemPrice,
discount: itemDiscount,
totalPrice: itemFinalPrice,
};
cart.items.push(cartItem);
}
}
// Recalculate cart totals once after all items are added
await this.recalculateCartTotals(cart);
await this.saveCart(cart);
return cart;
}
/**
* Update item quantity in cart
*/
async updateItemQuantity(
userId: string,
restaurantId: string,
foodId: string,
updateItemDto: UpdateItemQuantityDto,
): 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 item = cart.items[itemIndex];
// Find food to check stock
const food = await this.em.findOne(Food, { id: item.foodId }, { populate: ['restaurant'] });
if (!food) {
throw new NotFoundException(`Food with ID ${item.foodId} not found`);
}
// Check stock availability
if (food.stock < updateItemDto.quantity) {
throw new BadRequestException(`Insufficient stock for food ${food.title}. Available: ${food.stock}`);
}
// Update quantity
const itemPrice = food.price || 0;
const itemDiscount = food.discount || 0;
const itemTotalPrice = itemPrice * updateItemDto.quantity;
const itemTotalDiscount = itemDiscount * updateItemDto.quantity;
const itemFinalPrice = itemTotalPrice - itemTotalDiscount;
cart.items[itemIndex] = {
...item,
quantity: updateItemDto.quantity,
totalPrice: itemFinalPrice,
};
// Recalculate cart totals
await this.recalculateCartTotals(cart);
await this.saveCart(cart);
return cart;
}
/**
* Remove item from cart
*/
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;
}
/**
* Apply coupon to cart
*/
async applyCoupon(userId: string, restaurantId: string, applyCouponDto: ApplyCouponDto): Promise<Cart> {
const cart = await this.findOneOrFail(userId, restaurantId);
// Calculate current order amount (subtotal after item discounts) for coupon validation
// const currentSubTotal = cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
// const currentItemsDiscount = cart.items.reduce((sum, item) => sum + item.discount * item.quantity, 0);
// const orderAmount = currentSubTotal - currentItemsDiscount;
const orderAmount = cart.subTotal - cart.itemsDiscount;
// check if coupon is valid and belong to the restaurant
const { valid, coupon, message } = await this.couponService.validateCoupon(
applyCouponDto.code,
restaurantId,
Number(orderAmount),
);
if (!valid) {
throw new BadRequestException(message || 'Invalid coupon code');
}
if (!coupon) {
throw new BadRequestException('Coupon not found');
}
// Validate user phone if coupon is restricted to a specific user
if (coupon.userPhone) {
const user = await this.em.findOne(User, { id: userId });
if (!user) {
throw new NotFoundException('User not found');
}
if (user.phone !== coupon.userPhone) {
throw new BadRequestException(
'This coupon is only available for a specific user and cannot be applied to your account.',
);
}
}
// 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.',
);
}
}
const couponDetail: OrderCouponDetail = {
couponId: coupon.id,
couponName: coupon.name,
couponCode: coupon.code,
type: coupon.type,
value: coupon.value,
maxDiscount: coupon.maxDiscount,
};
cart.coupon = couponDetail;
// Recalculate cart totals
await this.recalculateCartTotals(cart);
await this.saveCart(cart);
return cart;
}
/**
* Remove coupon from cart
*/
async removeCoupon(userId: string, restaurantId: string): Promise<Cart> {
const cart = await this.findOneOrFail(userId, restaurantId);
cart.coupon = undefined;
// Recalculate cart totals
await this.recalculateCartTotals(cart);
await this.saveCart(cart);
return cart;
}
/**
* Set address for cart
*/
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');
// 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`);
}
// Verify address belongs to the user
if (address.user.id !== userId) {
throw new BadRequestException('Address does not belong to the current user');
}
cart.addressId = setAddressDto.addressId;
cart.updatedAt = new Date().toISOString();
await this.saveCart(cart);
return cart;
}
/**
* Set payment method for cart
*/
async setPaymentMethod(
userId: string,
restaurantId: string,
setPaymentMethodDto: SetPaymentMethodDto,
): 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');
}
cart.paymentMethodId = setPaymentMethodDto.paymentMethodId;
// Recalculate cart totals
await this.recalculateCartTotals(cart);
await this.saveCart(cart);
return cart;
}
/**
* Set delivery method for cart
*/
async setDeliveryMethod(
userId: string,
restaurantId: string,
setDeliveryMethodDto: SetDeliveryMethodDto,
): 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');
}
cart.deliveryMethodId = setDeliveryMethodDto.deliveryMethodId;
// Recalculate cart totals (this will update deliveryFee based on delivery method)
await this.recalculateCartTotals(cart);
await this.saveCart(cart);
return cart;
}
/**
* Set description for cart
*/
async setDescription(userId: string, restaurantId: string, setDescriptionDto: SetDescriptionDto): Promise<Cart> {
const cart = await this.findOneOrFail(userId, restaurantId);
cart.description = setDescriptionDto.description;
cart.updatedAt = new Date().toISOString();
await this.saveCart(cart);
return 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 itemPrice = item.price * item.quantity;
const itemDiscount = item.discount * item.quantity;
subTotal += itemPrice;
itemsDiscount += itemDiscount;
totalItems += item.quantity;
}
cart.subTotal = subTotal;
cart.itemsDiscount = itemsDiscount;
// Calculate coupon discount
let couponDiscount = 0;
if (cart.coupon) {
// Get the full coupon entity to check foodCategories and foods
const coupon = await this.couponService.findById(cart.coupon.couponId);
// Determine which items are eligible for the coupon
let eligibleItemsTotal = 0;
let eligibleItemsDiscount = 0;
// If coupon has foodCategories or foods restrictions, filter items
const hasCategoryRestriction = coupon.foodCategories && coupon.foodCategories.length > 0;
const hasFoodRestriction = coupon.foods && coupon.foods.length > 0;
if (hasCategoryRestriction || hasFoodRestriction) {
// Fetch all foods in cart with their categories
const foodIds = cart.items.map(item => item.foodId);
const foods = await this.em.find(Food, { id: { $in: foodIds } }, { populate: ['category'] });
const foodMap = new Map(foods.map(f => [f.id, f]));
for (const item of cart.items) {
const food = foodMap.get(item.foodId);
if (!food) continue;
// Check if item matches coupon criteria
const matchesCategory =
hasCategoryRestriction && food.category && coupon.foodCategories!.includes(food.category.id);
const matchesFood = hasFoodRestriction && coupon.foods!.includes(food.id);
// Item is eligible if it matches category OR food criteria
if (matchesCategory || matchesFood) {
const itemPrice = item.price * item.quantity;
const itemDiscount = item.discount * item.quantity;
eligibleItemsTotal += itemPrice;
eligibleItemsDiscount += itemDiscount;
}
}
} else {
// No restrictions, apply to all items
eligibleItemsTotal = subTotal;
eligibleItemsDiscount = itemsDiscount;
}
const priceAfterItemDiscount = eligibleItemsTotal - eligibleItemsDiscount;
if (cart.coupon.type === CouponType.PERCENTAGE) {
let discount = (priceAfterItemDiscount * cart.coupon.value) / 100;
// Apply maxDiscount limit if set
if (cart.coupon.maxDiscount && discount > cart.coupon.maxDiscount) {
discount = cart.coupon.maxDiscount;
}
couponDiscount = discount;
} else {
// FIXED amount discount
couponDiscount = Math.min(cart.coupon.value, priceAfterItemDiscount);
}
}
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 = subTotal - cart.totalDiscount;
tax = (priceAfterDiscounts * Number(restaurant.vat)) / 100;
}
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 = subTotal - cart.totalDiscount + tax + cart.deliveryFee;
cart.totalItems = totalItems;
cart.updatedAt = new Date().toISOString();
}
/**
* Type guard to check if an object is a Cart
*/
private isCart(obj: unknown): obj is Cart {
if (typeof obj !== 'object' || obj === null) {
return false;
}
const cart = obj as Record<string, unknown>;
return (
typeof cart.userId === 'string' &&
typeof cart.restaurantId === 'string' &&
Array.isArray(cart.items) &&
typeof cart.subTotal === 'number' &&
typeof cart.itemsDiscount === 'number' &&
typeof cart.couponDiscount === 'number' &&
typeof cart.totalDiscount === 'number' &&
typeof cart.tax === 'number' &&
typeof cart.deliveryFee === 'number' &&
typeof cart.total === 'number' &&
typeof cart.totalItems === 'number' &&
typeof cart.createdAt === 'string' &&
typeof cart.updatedAt === 'string'
);
}
/**
* Get cart by restaurant for a user
*/
private async getCartByRestaurant(userId: string, restaurantId: string): Promise<Cart | null> {
const cacheKey = `${this.CART_KEY_PREFIX}:${userId}:${restaurantId}`;
const cachedCart = await this.cacheService.get<string>(cacheKey);
if (cachedCart) {
try {
const parsed: unknown = JSON.parse(cachedCart);
if (this.isCart(parsed) && parsed.userId === userId && parsed.restaurantId === restaurantId) {
return parsed;
}
} catch {
// If parsing fails, return null
}
}
return null;
}
/**
* Save cart to cache
*/
private async saveCart(cart: Cart): Promise<void> {
const cacheKey = `${this.CART_KEY_PREFIX}:${cart.userId}:${cart.restaurantId}`;
await this.cacheService.set(cacheKey, JSON.stringify(cart), this.CART_TTL);
}
async clearCart(userId: string, restaurantId: string): Promise<void> {
const cacheKey = `${this.CART_KEY_PREFIX}:${userId}:${restaurantId}`;
return this.cacheService.del(cacheKey);
}
}