Files
dmenu-api/src/modules/cart/providers/cart.service.ts
T
2025-12-01 11:31:09 +03:30

583 lines
19 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 { UserAddress } from '../../users/entities/user-address.entity';
import { RestaurantPaymentMethod } from '../../payments/entities/restaurant-payment-method.entity';
import { Cart, CartItem, CartCoupon } from '../interfaces/cart.interface';
@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,
) {}
/**
* 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 findOne(userId: string, restaurantId: string): Promise<Cart> {
const cart = await this.getCartByRestaurant(userId, restaurantId);
if (!cart) {
throw new NotFoundException('Cart not found');
}
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.findOne(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.findOne(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.findOne(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.findOne(userId, restaurantId);
// TODO: Implement actual coupon validation logic
// For now, this is a placeholder implementation
// You should validate the coupon code against your coupon/discount system
// Example coupon structure (replace with your actual coupon validation)
const coupon: CartCoupon = {
code: applyCouponDto.code,
discount: 10, // Example: 10% discount
discountType: 'percentage', // or 'amount'
};
cart.coupon = coupon;
// 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.findOne(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.findOne(userId, restaurantId);
// 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.findOne(userId, restaurantId);
// Find and validate payment method belongs to restaurant
const restaurantPaymentMethod = await this.em.findOne(
RestaurantPaymentMethod,
{
restaurant: { id: restaurantId },
paymentMethod: { id: setPaymentMethodDto.paymentMethodId },
},
{ populate: ['restaurant', 'paymentMethod'] },
);
if (!restaurantPaymentMethod) {
throw new NotFoundException(
`Payment method with ID ${setPaymentMethodDto.paymentMethodId} not found for restaurant ${restaurantId}`,
);
}
// Verify payment method is active
if (!restaurantPaymentMethod.isActive) {
throw new BadRequestException('Payment method is not active for this restaurant');
}
// Verify the payment method itself is active
if (!restaurantPaymentMethod.paymentMethod.isActive) {
throw new BadRequestException('Payment method is not active');
}
cart.paymentMethodId = setPaymentMethodDto.paymentMethodId;
// cart.deliveryFee = Number(restaurantPaymentMethod.price) || 0;
// Recalculate cart totals to include delivery fee
await this.recalculateCartTotals(cart);
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) {
if (cart.coupon.discountType === 'percentage') {
const priceAfterItemDiscount = subTotal - itemsDiscount;
couponDiscount = (priceAfterItemDiscount * cart.coupon.discount) / 100;
} else {
// amount discount
const priceAfterItemDiscount = subTotal - itemsDiscount;
couponDiscount = Math.min(cart.coupon.discount, 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;
// Get delivery fee if payment method is set
let deliveryFee = 0;
// if (cart.paymentMethodId) {
// const restaurantPaymentMethod = await this.em.findOne(RestaurantPaymentMethod, {
// restaurant: { id: cart.restaurantId },
// paymentMethod: { id: cart.paymentMethodId },
// });
// if (restaurantPaymentMethod) {
// deliveryFee = Number(restaurantPaymentMethod.price) || 0;
// deliveryFee = 1;
// }
// }
cart.deliveryFee = deliveryFee;
// Calculate total: total = subtotal totalDiscount + tax + deliveryFee
cart.total = subTotal - cart.totalDiscount + tax + 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);
}
}