Files
dmenu-api/src/modules/cart/providers/cart.service.ts
T
2025-11-30 22:32:08 +03:30

525 lines
17 KiB
TypeScript

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 { ulid } from 'ulid';
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: [],
paymentMethodFee: 0,
totalPrice: 0,
totalDiscount: 0,
couponDiscount: 0,
vatAmount: 0,
finalPrice: 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, addItemDto: AddItemToCartDto): Promise<Cart> {
const cart = await this.getOrCreateCart(userId, restaurantId);
// 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 = {
itemId: ulid(),
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;
}
/**
* 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 = {
itemId: ulid(),
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,
itemId: string,
updateItemDto: UpdateItemQuantityDto,
): Promise<Cart> {
const cart = await this.findOne(userId, restaurantId);
const itemIndex = cart.items.findIndex(item => item.itemId === itemId);
if (itemIndex < 0) {
throw new NotFoundException(`Item with ID ${itemId} 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, itemId: string): Promise<Cart> {
const cart = await this.findOne(userId, restaurantId);
const itemIndex = cart.items.findIndex(item => item.itemId === itemId);
if (itemIndex < 0) {
throw new NotFoundException(`Item with ID ${itemId} 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.paymentMethodFee = Number(restaurantPaymentMethod.price) || 0;
// Recalculate cart totals to include payment method fee
await this.recalculateCartTotals(cart);
await this.saveCart(cart);
return cart;
}
/**
* Recalculate cart totals (including coupon discount and VAT)
*/
private async recalculateCartTotals(cart: Cart): Promise<void> {
let totalPrice = 0;
let totalDiscount = 0;
let totalItems = 0;
for (const item of cart.items) {
const itemPrice = item.price * item.quantity;
const itemDiscount = item.discount * item.quantity;
totalPrice += itemPrice;
totalDiscount += itemDiscount;
totalItems += item.quantity;
}
cart.totalPrice = totalPrice;
cart.totalDiscount = totalDiscount;
// Calculate coupon discount
let couponDiscount = 0;
if (cart.coupon) {
if (cart.coupon.discountType === 'percentage') {
const priceAfterItemDiscount = totalPrice - totalDiscount;
couponDiscount = (priceAfterItemDiscount * cart.coupon.discount) / 100;
} else {
// amount discount
const priceAfterItemDiscount = totalPrice - totalDiscount;
couponDiscount = Math.min(cart.coupon.discount, priceAfterItemDiscount);
}
}
cart.couponDiscount = couponDiscount;
const priceAfterDiscounts = totalPrice - totalDiscount - couponDiscount;
// Calculate VAT if restaurant has VAT rate > 0
let vatAmount = 0;
const restaurant = await this.em.findOne(Restaurant, { id: cart.restaurantId });
if (restaurant && restaurant.vat && restaurant.vat > 0) {
// VAT is calculated on the price after discounts and coupons
vatAmount = (priceAfterDiscounts * Number(restaurant.vat)) / 100;
}
cart.vatAmount = vatAmount;
// Get payment method fee if payment method is set
let paymentMethodFee = 0;
// if (cart.paymentMethodId) {
// const restaurantPaymentMethod = await this.em.findOne(RestaurantPaymentMethod, {
// restaurant: { id: cart.restaurantId },
// paymentMethod: { id: cart.paymentMethodId },
// });
// if (restaurantPaymentMethod) {
// paymentMethodFee = Number(restaurantPaymentMethod.price) || 0;
// paymentMethodFee = 1;
// }
// }
cart.paymentMethodFee = paymentMethodFee;
// Final price includes items, discounts, VAT, and payment method fee
cart.finalPrice = priceAfterDiscounts + vatAmount + paymentMethodFee;
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.totalPrice === 'number' &&
typeof cart.totalDiscount === 'number' &&
typeof cart.couponDiscount === 'number' &&
typeof cart.vatAmount === 'number' &&
typeof cart.finalPrice === '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);
}
}