525 lines
17 KiB
TypeScript
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);
|
|
}
|
|
}
|