refactor cart
This commit is contained in:
@@ -6,21 +6,44 @@ import { Food } from '../foods/entities/food.entity';
|
||||
import { Restaurant } from '../restaurants/entities/restaurant.entity';
|
||||
import { PaymentMethod } from '../payments/entities/payment-method.entity';
|
||||
import { UserAddress } from '../users/entities/user-address.entity';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
import { UserWallet } from '../users/entities/user-wallet.entity';
|
||||
import { Delivery } from '../delivery/entities/delivery.entity';
|
||||
import { Order } from '../orders/entities/order.entity';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { UtilsModule } from '../utils/utils.module';
|
||||
import { CouponModule } from '../coupons/coupon.module';
|
||||
import { CartRepository } from './repositories/cart.repository';
|
||||
import { CartValidationService } from './providers/cart-validation.service';
|
||||
import { CartCalculationService } from './providers/cart-calculation.service';
|
||||
import { CartItemService } from './providers/cart-item.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MikroOrmModule.forFeature([Food, Restaurant, PaymentMethod, UserAddress]),
|
||||
MikroOrmModule.forFeature([
|
||||
Food,
|
||||
Restaurant,
|
||||
PaymentMethod,
|
||||
UserAddress,
|
||||
User,
|
||||
UserWallet,
|
||||
Delivery,
|
||||
Order,
|
||||
]),
|
||||
AuthModule,
|
||||
JwtModule,
|
||||
UtilsModule,
|
||||
CouponModule,
|
||||
],
|
||||
controllers: [CartController],
|
||||
providers: [CartService],
|
||||
providers: [
|
||||
CartService,
|
||||
CartRepository,
|
||||
CartValidationService,
|
||||
CartCalculationService,
|
||||
CartItemService,
|
||||
],
|
||||
exports: [CartService],
|
||||
})
|
||||
export class CartModule {}
|
||||
|
||||
@@ -87,73 +87,7 @@ export class CartController {
|
||||
return this.cartService.removeCoupon(userId, restaurantId);
|
||||
}
|
||||
|
||||
// @Patch('address')
|
||||
// @ApiOperation({ summary: 'Set address for cart' })
|
||||
// @ApiHeader(API_HEADER_SLUG)
|
||||
// @ApiBody({ type: SetAddressDto })
|
||||
// async setAddress(@UserId() userId: string, @RestId() restaurantId: string, @Body() setAddressDto: SetAddressDto) {
|
||||
// return this.cartService.setAddress(userId, restaurantId, setAddressDto);
|
||||
// }
|
||||
|
||||
// @Patch('payment-method')
|
||||
// @ApiOperation({ summary: 'Set payment method for cart' })
|
||||
// @ApiHeader(API_HEADER_SLUG)
|
||||
// @ApiBody({ type: SetPaymentMethodDto })
|
||||
// async setPaymentMethod(
|
||||
// @UserId() userId: string,
|
||||
// @RestId() restaurantId: string,
|
||||
// @Body() setPaymentMethodDto: SetPaymentMethodDto,
|
||||
// ) {
|
||||
// return this.cartService.setPaymentMethod(userId, restaurantId, setPaymentMethodDto);
|
||||
// }
|
||||
|
||||
// @Patch('delivery-method')
|
||||
// @ApiOperation({ summary: 'Set delivery method for cart' })
|
||||
// @ApiHeader(API_HEADER_SLUG)
|
||||
// @ApiBody({ type: SetDeliveryMethodDto })
|
||||
// setDeliveryMethod(
|
||||
// @UserId() userId: string,
|
||||
// @RestId() restaurantId: string,
|
||||
// @Body() setDeliveryMethodDto: SetDeliveryMethodDto,
|
||||
// ) {
|
||||
// return this.cartService.setDeliveryMethod(userId, restaurantId, setDeliveryMethodDto);
|
||||
// }
|
||||
|
||||
// @Patch('description')
|
||||
// @ApiOperation({ summary: 'Set description for cart' })
|
||||
// @ApiHeader(API_HEADER_SLUG)
|
||||
// @ApiBody({ type: SetDescriptionDto })
|
||||
// setDescription(
|
||||
// @UserId() userId: string,
|
||||
// @RestId() restaurantId: string,
|
||||
// @Body() setDescriptionDto: SetDescriptionDto,
|
||||
// ) {
|
||||
// return this.cartService.setDescription(userId, restaurantId, setDescriptionDto);
|
||||
// }
|
||||
|
||||
// @Patch('table-number')
|
||||
// @ApiOperation({ summary: 'Set table number for cart (required when delivery method is DineIn)' })
|
||||
// @ApiHeader(API_HEADER_SLUG)
|
||||
// @ApiBody({ type: SetTableNumberDto })
|
||||
// setTableNumber(
|
||||
// @UserId() userId: string,
|
||||
// @RestId() restaurantId: string,
|
||||
// @Body() setTableNumberDto: SetTableNumberDto,
|
||||
// ) {
|
||||
// return this.cartService.setTableNumber(userId, restaurantId, setTableNumberDto);
|
||||
// }
|
||||
|
||||
// @Patch('car-delivery')
|
||||
// @ApiOperation({ summary: 'Set car delivery for cart' })
|
||||
// @ApiHeader(API_HEADER_SLUG)
|
||||
// @ApiBody({ type: SetCarDeliveryDto })
|
||||
// setCarDelivery(
|
||||
// @UserId() userId: string,
|
||||
// @RestId() restaurantId: string,
|
||||
// @Body() setCarDeliveryDto: SetCarDeliveryDto,
|
||||
// ) {
|
||||
// return this.cartService.setCarDelivery(userId, restaurantId, setCarDeliveryDto);
|
||||
// }
|
||||
@Patch('all')
|
||||
@ApiOperation({ summary: 'Set all cart params' })
|
||||
@ApiHeader(API_HEADER_SLUG)
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EntityManager } from '@mikro-orm/postgresql';
|
||||
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||
import { Delivery } from '../../delivery/entities/delivery.entity';
|
||||
import { DeliveryFeeTypeEnum } from '../../delivery/interface/delivery';
|
||||
import { CouponType } from '../../coupons/interface/coupon';
|
||||
import { Food } from '../../foods/entities/food.entity';
|
||||
import { Cart, CartItem } from '../interfaces/cart.interface';
|
||||
import { CouponService } from '../../coupons/providers/coupon.service';
|
||||
import { GeographicUtils } from '../utils/geographic.utils';
|
||||
|
||||
@Injectable()
|
||||
export class CartCalculationService {
|
||||
constructor(
|
||||
private readonly em: EntityManager,
|
||||
private readonly couponService: CouponService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Calculate total price for a cart item
|
||||
*/
|
||||
calculateItemTotalPrice(unitPrice: number, unitDiscount: number, quantity: number): number {
|
||||
const safeUnitPrice = Number(unitPrice) || 0;
|
||||
const safeUnitDiscount = Math.min(Number(unitDiscount) || 0, safeUnitPrice);
|
||||
const itemTotalPrice = safeUnitPrice * quantity;
|
||||
const itemTotalDiscount = safeUnitDiscount * quantity;
|
||||
return itemTotalPrice - itemTotalDiscount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate items totals (subtotal, items discount, total items)
|
||||
*/
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate coupon discount
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate tax
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate delivery fee
|
||||
*/
|
||||
async calculateDeliveryFee(cart: Cart): Promise<number> {
|
||||
const deliveryMethodId = cart.deliveryMethodId;
|
||||
if (!deliveryMethodId) return 0;
|
||||
|
||||
const deliveryMethod = await this.em.findOne(Delivery, { id: deliveryMethodId }, { populate: ['restaurant'] });
|
||||
if (!deliveryMethod || !deliveryMethod.enabled) return 0;
|
||||
|
||||
// If not distance based, return fixed delivery fee
|
||||
if (deliveryMethod.deliveryFeeType !== DeliveryFeeTypeEnum.DISTANCE_BASED) {
|
||||
return Number(deliveryMethod.deliveryFee) || 0;
|
||||
}
|
||||
|
||||
// For distance based calculation we need restaurant and user coordinates
|
||||
const restaurant = (deliveryMethod as any).restaurant as Restaurant | undefined;
|
||||
const userAddr = cart.userAddress;
|
||||
|
||||
if (!restaurant || restaurant.latitude == null || restaurant.longitude == null) {
|
||||
// fallback to configured fixed fee when coordinates are missing
|
||||
return Number(deliveryMethod.deliveryFee) || 0;
|
||||
}
|
||||
|
||||
if (!userAddr || userAddr.latitude == null || userAddr.longitude == null) {
|
||||
return Number(deliveryMethod.deliveryFee) || 0;
|
||||
}
|
||||
|
||||
const restLat = Number(restaurant.latitude);
|
||||
const restLng = Number(restaurant.longitude);
|
||||
const userLat = Number(userAddr.latitude);
|
||||
const userLng = Number(userAddr.longitude);
|
||||
|
||||
const distanceKm = GeographicUtils.getDistanceKmRounded(restLat, restLng, userLat, userLng);
|
||||
|
||||
// Try to read either possible property names (legacy vs new)
|
||||
const perKm = Number(deliveryMethod.perKilometerFee);
|
||||
const minFee = Number(deliveryMethod.distanceBasedMinCost);
|
||||
|
||||
let fee = 0;
|
||||
if (perKm <= 0) {
|
||||
fee = Number(deliveryMethod.deliveryFee) || 0;
|
||||
} else {
|
||||
fee = distanceKm * perKm;
|
||||
}
|
||||
|
||||
if (minFee > 0 && fee < minFee) fee = minFee;
|
||||
|
||||
return Math.max(0, Number(fee));
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate cart totals (including coupon discount and tax)
|
||||
*/
|
||||
async recalculateCartTotals(cart: Cart): Promise<void> {
|
||||
const { subTotal, itemsDiscount, totalItems } = this.calculateItemsTotals(cart);
|
||||
cart.subTotal = subTotal;
|
||||
cart.itemsDiscount = itemsDiscount;
|
||||
|
||||
const couponDiscount = await this.calculateCouponDiscount(cart, subTotal, itemsDiscount);
|
||||
cart.couponDiscount = couponDiscount;
|
||||
cart.totalDiscount = couponDiscount + itemsDiscount;
|
||||
|
||||
cart.tax = await this.calculateTax(cart.restaurantId, Math.max(0, subTotal - cart.totalDiscount));
|
||||
cart.deliveryFee = await this.calculateDeliveryFee(cart);
|
||||
|
||||
// total = subtotal – totalDiscount + tax + deliveryFee
|
||||
cart.total = Math.max(0, subTotal - cart.totalDiscount) + cart.tax + cart.deliveryFee;
|
||||
cart.totalItems = totalItems;
|
||||
cart.updatedAt = this.nowIso();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get coupon restrictions or clear if invalid
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get foods in cart with categories
|
||||
*/
|
||||
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]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current ISO timestamp
|
||||
*/
|
||||
private nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Food } from '../../foods/entities/food.entity';
|
||||
import { Cart, CartItem } from '../interfaces/cart.interface';
|
||||
import { CartValidationService } from './cart-validation.service';
|
||||
import { CartCalculationService } from './cart-calculation.service';
|
||||
|
||||
@Injectable()
|
||||
export class CartItemService {
|
||||
constructor(
|
||||
private readonly validationService: CartValidationService,
|
||||
private readonly calculationService: CartCalculationService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a new cart item from food
|
||||
*/
|
||||
createCartItem(food: Food, quantity: number): CartItem {
|
||||
const itemPrice = food.price || 0;
|
||||
const itemDiscount = food.discount || 0;
|
||||
|
||||
return {
|
||||
foodId: food.id,
|
||||
foodTitle: food.title,
|
||||
quantity,
|
||||
price: itemPrice,
|
||||
discount: itemDiscount,
|
||||
totalPrice: this.calculationService.calculateItemTotalPrice(itemPrice, itemDiscount, quantity),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build cart item from food (updating existing item if provided)
|
||||
*/
|
||||
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.calculationService.calculateItemTotalPrice(itemPrice, itemDiscount, quantity),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get item index in cart
|
||||
*/
|
||||
getItemIndex(cart: Cart, foodId: string): number {
|
||||
return cart.items.findIndex(item => item.foodId === foodId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or increment item in cart
|
||||
*/
|
||||
async addOrIncrementItem(cart: Cart, foodId: string, restaurantId: string, quantityToAdd: number): Promise<void> {
|
||||
const food = await this.validationService.validateAndGetFood(foodId, restaurantId, quantityToAdd);
|
||||
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.validationService.validateStock(food, newQuantity);
|
||||
cart.items[index] = this.buildCartItemFromFood(food, newQuantity, existingItem);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove item from cart
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrement item quantity or remove if quantity reaches 0
|
||||
*/
|
||||
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.validationService.getFoodOrFail(foodId);
|
||||
cart.items[itemIndex] = this.buildCartItemFromFood(food, newQuantity, existingItem);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { EntityManager } from '@mikro-orm/postgresql';
|
||||
import { Food } from '../../foods/entities/food.entity';
|
||||
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||
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 { DeliveryMethodEnum } from '../../delivery/interface/delivery';
|
||||
import { PaymentMethodEnum } from '../../payments/interface/payment';
|
||||
import { UserWallet } from '../../users/entities/user-wallet.entity';
|
||||
import { Order } from '../../orders/entities/order.entity';
|
||||
import { OrderStatus } from '../../orders/interface/order.interface';
|
||||
import { Cart } from '../interfaces/cart.interface';
|
||||
import { GeographicUtils } from '../utils/geographic.utils';
|
||||
|
||||
@Injectable()
|
||||
export class CartValidationService {
|
||||
constructor(private readonly em: EntityManager) {}
|
||||
|
||||
/**
|
||||
* Validate food exists, belongs to restaurant, has inventory, and check stock
|
||||
*/
|
||||
async validateAndGetFood(foodId: string, restaurantId: string, quantity: number): Promise<Food> {
|
||||
const food = await this.em.findOne(Food, { id: foodId }, { populate: ['restaurant', 'inventory'] });
|
||||
if (!food) {
|
||||
throw new NotFoundException(`Food with ID ${foodId} not found`);
|
||||
}
|
||||
|
||||
if (food.restaurant.id !== restaurantId) {
|
||||
throw new BadRequestException(`Food ${food.title || food.id} does not belong to restaurant ${restaurantId}`);
|
||||
}
|
||||
|
||||
if (!food.inventory) {
|
||||
throw new BadRequestException(`The food ${food.title || food.id} does not have inventory`);
|
||||
}
|
||||
|
||||
this.validateStock(food, quantity);
|
||||
return food;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate stock availability for food
|
||||
*/
|
||||
validateStock(food: Food, quantity: number): void {
|
||||
const availableStock = food.inventory!.availableStock;
|
||||
if (availableStock < quantity) {
|
||||
throw new BadRequestException(`Insufficient stock for food ${food.title}. Available: ${availableStock}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user or throw if not found
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user address or throw if not found
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get food or throw if not found
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get restaurant or throw if not found
|
||||
*/
|
||||
async getRestaurantOrFail(restaurantId: string): Promise<Restaurant> {
|
||||
const restaurant = await this.em.findOne(Restaurant, { id: restaurantId });
|
||||
if (!restaurant) {
|
||||
throw new NotFoundException('Restaurant not found');
|
||||
}
|
||||
return restaurant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate address belongs to user
|
||||
*/
|
||||
validateAddressOwnership(address: UserAddress, userId: string): void {
|
||||
if (address.user.id !== userId) {
|
||||
throw new BadRequestException('Address does not belong to the current user');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert address is inside restaurant service area
|
||||
*/
|
||||
async assertAddressInsideServiceArea(
|
||||
restaurantId: string,
|
||||
latitude?: number | null,
|
||||
longitude?: number | null,
|
||||
): Promise<void> {
|
||||
if (latitude === undefined || latitude === null || longitude === undefined || longitude === null) {
|
||||
throw new BadRequestException('Address does not have coordinates');
|
||||
}
|
||||
|
||||
const restaurant = await this.getRestaurantOrFail(restaurantId);
|
||||
|
||||
const serviceArea = restaurant.serviceArea;
|
||||
// If no service area is defined, assume service is available everywhere
|
||||
if (!serviceArea || !serviceArea.coordinates || !Array.isArray(serviceArea.coordinates)) return;
|
||||
|
||||
// GeoJSON coordinates: [ [ [lng, lat], ... ] ] -> take first ring
|
||||
const rings = serviceArea.coordinates;
|
||||
if (!rings || rings.length === 0 || !Array.isArray(rings[0])) return;
|
||||
|
||||
const ring = rings[0];
|
||||
const point: [number, number] = [Number(longitude), Number(latitude)];
|
||||
|
||||
// Ensure polygon has the correct tuple typing ([lng, lat] pairs)
|
||||
const polygon: [number, number][] = ring.map(
|
||||
coord => [Number(coord[0] ?? 0), Number(coord[1] ?? 0)] as [number, number],
|
||||
);
|
||||
|
||||
if (!GeographicUtils.isPointInPolygon(point, polygon)) {
|
||||
throw new BadRequestException('Address is outside the restaurant service area');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get delivery method for restaurant or throw if not found
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enabled delivery method or throw if not found or disabled
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert delivery method matches expected type
|
||||
*/
|
||||
assertDeliveryMethod(actual: DeliveryMethodEnum, expected: DeliveryMethodEnum, message: string): void {
|
||||
if (actual !== expected) {
|
||||
throw new BadRequestException(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Require delivery method ID or throw
|
||||
*/
|
||||
requireDeliveryMethodId(cart: Cart, message: string): string {
|
||||
if (!cart.deliveryMethodId) {
|
||||
throw new BadRequestException(message);
|
||||
}
|
||||
return cart.deliveryMethodId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enabled payment method or throw if not found or disabled
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert wallet has enough balance
|
||||
*/
|
||||
async assertWalletHasEnoughBalance(userId: string, restaurantId: string, amount: number): Promise<void> {
|
||||
const wallet = await this.em.findOne(UserWallet, {
|
||||
user: { id: userId },
|
||||
restaurant: { id: restaurantId },
|
||||
});
|
||||
|
||||
if (!wallet) {
|
||||
throw new BadRequestException('User wallet not found');
|
||||
}
|
||||
|
||||
if (wallet.wallet < amount) {
|
||||
throw new BadRequestException('User wallet is not enough');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert coupon usage limit
|
||||
*/
|
||||
async assertCouponUsageLimit(userId: string, couponId: string, maxUsesPerUser?: number): Promise<void> {
|
||||
if (!maxUsesPerUser) return;
|
||||
|
||||
const ordersThatUsedTheCouponCount = await this.em.count(Order, {
|
||||
user: { id: userId },
|
||||
couponDetail: { couponId: couponId },
|
||||
status: { $ne: OrderStatus.CANCELED },
|
||||
deletedAt: null,
|
||||
});
|
||||
|
||||
if (ordersThatUsedTheCouponCount >= maxUsesPerUser) {
|
||||
throw new BadRequestException(`You have reached the maximum number of uses (${maxUsesPerUser}) for this coupon`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert coupon matches cart if restricted
|
||||
*/
|
||||
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.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get foods in cart with categories
|
||||
*/
|
||||
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]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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 { DeliveryMethodEnum } from '../../delivery/interface/delivery';
|
||||
import { PaymentMethodEnum } from '../../payments/interface/payment';
|
||||
import { OrderCouponDetail } from '../../orders/interface/order.interface';
|
||||
import { Cart } from '../interfaces/cart.interface';
|
||||
import { BulkAddItemsToCartDto } from '../dto/bulk-add-items.dto';
|
||||
import { ApplyCouponDto } from '../dto/apply-coupon.dto';
|
||||
import { SetAddressDto } from '../dto/set-address.dto';
|
||||
@@ -11,31 +12,27 @@ import { SetDeliveryMethodDto } from '../dto/set-delivery-method.dto';
|
||||
import { SetDescriptionDto } from '../dto/set-description.dto';
|
||||
import { SetTableNumberDto } from '../dto/set-table-number.dto';
|
||||
import { SetCarDeliveryDto } from '../dto/set-car-delivery.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 { DeliveryMethodEnum, DeliveryFeeTypeEnum } from '../../delivery/interface/delivery';
|
||||
import { Cart, CartItem } from '../interfaces/cart.interface';
|
||||
import { CouponService } from 'src/modules/coupons/providers/coupon.service';
|
||||
import { OrderCouponDetail, OrderStatus } from 'src/modules/orders/interface/order.interface';
|
||||
import { CouponType } from 'src/modules/coupons/interface/coupon';
|
||||
import { UserWallet } from 'src/modules/users/entities/user-wallet.entity';
|
||||
import { PaymentMethodEnum } from 'src/modules/payments/interface/payment';
|
||||
import { SetAllCartParmsDto } from '../dto/set-all-cart-params.dto';
|
||||
import { Order } from 'src/modules/orders/entities/order.entity';
|
||||
import { CouponService } from '../../coupons/providers/coupon.service';
|
||||
import { CartRepository } from '../repositories/cart.repository';
|
||||
import { CartValidationService } from './cart-validation.service';
|
||||
import { CartCalculationService } from './cart-calculation.service';
|
||||
import { CartItemService } from './cart-item.service';
|
||||
|
||||
@Injectable()
|
||||
export class CartService {
|
||||
private readonly CART_TTL = 3600; // 1 hour in seconds
|
||||
private readonly CART_KEY_PREFIX = 'cart';
|
||||
|
||||
constructor(
|
||||
private readonly em: EntityManager,
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly cartRepository: CartRepository,
|
||||
private readonly validationService: CartValidationService,
|
||||
private readonly calculationService: CartCalculationService,
|
||||
private readonly itemService: CartItemService,
|
||||
private readonly couponService: CouponService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Set all cart parameters at once
|
||||
*/
|
||||
async setAllCartParams(userId: string, restaurantId: string, params: SetAllCartParmsDto): Promise<Cart> {
|
||||
const { deliveryMethodId, paymentMethodId, addressId, carAddress, description } = params;
|
||||
|
||||
@@ -44,7 +41,10 @@ export class CartService {
|
||||
|
||||
// If deliveryMethodId is provided -> validate and set it first
|
||||
if (deliveryMethodId) {
|
||||
const deliveryMethod = await this.getEnabledDeliveryMethodOrFail(restaurantId, deliveryMethodId);
|
||||
const deliveryMethod = await this.validationService.getEnabledDeliveryMethodOrFail(
|
||||
restaurantId,
|
||||
deliveryMethodId,
|
||||
);
|
||||
cart.deliveryMethodId = deliveryMethodId;
|
||||
// clear fields incompatible with the chosen delivery method
|
||||
this.clearFieldsIncompatibleWithDeliveryMethod(cart, deliveryMethod.method);
|
||||
@@ -56,20 +56,25 @@ export class CartService {
|
||||
if (!effectiveDeliveryMethodId) {
|
||||
throw new BadRequestException('Delivery method must be set before setting address');
|
||||
}
|
||||
const deliveryMethod = await this.getDeliveryMethodForRestaurantOrFail(restaurantId, effectiveDeliveryMethodId);
|
||||
this.assertDeliveryMethod(
|
||||
const deliveryMethod = await this.validationService.getDeliveryMethodForRestaurantOrFail(
|
||||
restaurantId,
|
||||
effectiveDeliveryMethodId,
|
||||
);
|
||||
this.validationService.assertDeliveryMethod(
|
||||
deliveryMethod.method,
|
||||
DeliveryMethodEnum.DeliveryCourier,
|
||||
'Delivery method must be DeliveryCourier',
|
||||
);
|
||||
|
||||
const address = await this.getUserAddressOrFail(addressId);
|
||||
if (address.user.id !== userId) {
|
||||
throw new BadRequestException('Address does not belong to the current user');
|
||||
}
|
||||
const address = await this.validationService.getUserAddressOrFail(addressId);
|
||||
this.validationService.validateAddressOwnership(address, userId);
|
||||
|
||||
// ensure address is within restaurant service area
|
||||
await this.assertAddressInsideServiceArea(restaurantId, address.latitude, address.longitude);
|
||||
await this.validationService.assertAddressInsideServiceArea(
|
||||
restaurantId,
|
||||
address.latitude,
|
||||
address.longitude,
|
||||
);
|
||||
|
||||
// ensure cart fields are compatible and set address
|
||||
this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DeliveryCourier);
|
||||
@@ -91,14 +96,17 @@ export class CartService {
|
||||
if (!effectiveDeliveryMethodId) {
|
||||
throw new BadRequestException('Delivery method must be set before setting car delivery');
|
||||
}
|
||||
const deliveryMethod = await this.getDeliveryMethodForRestaurantOrFail(restaurantId, effectiveDeliveryMethodId);
|
||||
this.assertDeliveryMethod(
|
||||
const deliveryMethod = await this.validationService.getDeliveryMethodForRestaurantOrFail(
|
||||
restaurantId,
|
||||
effectiveDeliveryMethodId,
|
||||
);
|
||||
this.validationService.assertDeliveryMethod(
|
||||
deliveryMethod.method,
|
||||
DeliveryMethodEnum.DeliveryCar,
|
||||
'Delivery method must be DeliveryCar',
|
||||
);
|
||||
|
||||
const user = await this.getUserOrFail(userId);
|
||||
const user = await this.validationService.getUserOrFail(userId);
|
||||
|
||||
// make sure incompatible fields are cleared and set car address (phone comes from user)
|
||||
this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DeliveryCar);
|
||||
@@ -112,13 +120,16 @@ export class CartService {
|
||||
|
||||
// If payment method is provided -> validate and (for wallet) ensure enough balance.
|
||||
if (paymentMethodId) {
|
||||
const paymentMethod = await this.getEnabledPaymentMethodOrFail(restaurantId, paymentMethodId);
|
||||
const paymentMethod = await this.validationService.getEnabledPaymentMethodOrFail(
|
||||
restaurantId,
|
||||
paymentMethodId,
|
||||
);
|
||||
|
||||
// Recalculate totals first so wallet check uses up-to-date total (delivery method may have changed above).
|
||||
await this.recalculateCartTotals(cart);
|
||||
await this.calculationService.recalculateCartTotals(cart);
|
||||
|
||||
if (paymentMethod.method === PaymentMethodEnum.Wallet) {
|
||||
await this.assertWalletHasEnoughBalance(userId, restaurantId, cart.total);
|
||||
await this.validationService.assertWalletHasEnoughBalance(userId, restaurantId, cart.total);
|
||||
}
|
||||
|
||||
cart.paymentMethodId = paymentMethodId;
|
||||
@@ -137,16 +148,13 @@ export class CartService {
|
||||
* Get or create cart for user and restaurant
|
||||
*/
|
||||
async getOrCreateCart(userId: string, restaurantId: string): Promise<Cart> {
|
||||
const existingCart = await this.getCartByRestaurant(userId, restaurantId);
|
||||
const existingCart = await this.cartRepository.findByUserAndRestaurant(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');
|
||||
}
|
||||
const restaurant = await this.validationService.getRestaurantOrFail(restaurantId);
|
||||
|
||||
// Create new cart
|
||||
const now = this.nowIso();
|
||||
@@ -167,7 +175,7 @@ export class CartService {
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await this.saveCart(cart);
|
||||
await this.cartRepository.save(cart);
|
||||
return cart;
|
||||
}
|
||||
|
||||
@@ -175,7 +183,7 @@ export class CartService {
|
||||
* Find cart by user and restaurant
|
||||
*/
|
||||
async findOneOrFail(userId: string, restaurantId: string): Promise<Cart> {
|
||||
const cart = await this.getCartByRestaurant(userId, restaurantId);
|
||||
const cart = await this.cartRepository.findByUserAndRestaurant(userId, restaurantId);
|
||||
if (!cart) {
|
||||
throw new NotFoundException('Cart not found');
|
||||
}
|
||||
@@ -183,13 +191,11 @@ export class CartService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment item quantity in cart by 1
|
||||
* Increment item quantity in cart
|
||||
*/
|
||||
async incrementItem(userId: string, restaurantId: string, foodId: string, quantity: number): Promise<Cart> {
|
||||
const cart = await this.getOrCreateCart(userId, restaurantId);
|
||||
const food = await this.validateAndGetFood(foodId, restaurantId, quantity);
|
||||
|
||||
this.addOrIncrementItem(cart, food, quantity);
|
||||
await this.itemService.addOrIncrementItem(cart, foodId, restaurantId, quantity);
|
||||
return this.recalculateAndSaveCart(cart);
|
||||
}
|
||||
|
||||
@@ -198,7 +204,7 @@ export class CartService {
|
||||
*/
|
||||
async decrementItem(userId: string, restaurantId: string, foodId: string): Promise<Cart> {
|
||||
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||
await this.decrementOrRemoveItem(cart, foodId);
|
||||
await this.itemService.decrementOrRemoveItem(cart, foodId);
|
||||
return this.recalculateAndSaveCart(cart);
|
||||
}
|
||||
|
||||
@@ -209,8 +215,7 @@ export class CartService {
|
||||
const cart = await this.getOrCreateCart(userId, restaurantId);
|
||||
|
||||
for (const addItemDto of bulkAddItemsDto.items) {
|
||||
const food = await this.validateAndGetFood(addItemDto.foodId, restaurantId, addItemDto.quantity);
|
||||
this.addOrIncrementItem(cart, food, addItemDto.quantity);
|
||||
await this.itemService.addOrIncrementItem(cart, addItemDto.foodId, restaurantId, addItemDto.quantity);
|
||||
}
|
||||
|
||||
return this.recalculateAndSaveCart(cart);
|
||||
@@ -221,7 +226,7 @@ export class CartService {
|
||||
*/
|
||||
async removeItem(userId: string, restaurantId: string, foodId: string): Promise<Cart> {
|
||||
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||
this.removeItemOrFail(cart, foodId);
|
||||
this.itemService.removeItemOrFail(cart, foodId);
|
||||
return this.recalculateAndSaveCart(cart);
|
||||
}
|
||||
|
||||
@@ -232,7 +237,7 @@ export class CartService {
|
||||
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||
|
||||
const orderAmount = cart.subTotal - cart.itemsDiscount;
|
||||
const user = await this.getUserOrFail(userId);
|
||||
const user = await this.validationService.getUserOrFail(userId);
|
||||
// check if coupon is valid and belong to the restaurant
|
||||
const { valid, coupon, message } = await this.couponService.validateCoupon(
|
||||
applyCouponDto.code,
|
||||
@@ -250,8 +255,12 @@ export class CartService {
|
||||
}
|
||||
|
||||
// Check maxUsesPerUser limit by counting how many orders this user has with this coupon
|
||||
await this.assertCouponUsageLimit(userId, coupon.id, coupon.maxUsesPerUser);
|
||||
await this.assertCouponMatchesCartIfRestricted(cart, coupon.foodCategories, coupon.foods);
|
||||
await this.validationService.assertCouponUsageLimit(userId, coupon.id, coupon.maxUsesPerUser);
|
||||
await this.validationService.assertCouponMatchesCartIfRestricted(
|
||||
cart,
|
||||
coupon.foodCategories,
|
||||
coupon.foods,
|
||||
);
|
||||
|
||||
const couponDetail: OrderCouponDetail = {
|
||||
couponId: coupon.id,
|
||||
@@ -282,24 +291,32 @@ export class CartService {
|
||||
async setAddress(userId: string, restaurantId: string, setAddressDto: SetAddressDto): Promise<Cart> {
|
||||
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||
|
||||
const deliveryMethodId = this.requireDeliveryMethodId(cart, 'Delivery method must be set before setting address');
|
||||
const deliveryMethod = await this.getDeliveryMethodForRestaurantOrFail(restaurantId, deliveryMethodId);
|
||||
this.assertDeliveryMethod(
|
||||
const deliveryMethodId = this.validationService.requireDeliveryMethodId(
|
||||
cart,
|
||||
'Delivery method must be set before setting address',
|
||||
);
|
||||
const deliveryMethod = await this.validationService.getDeliveryMethodForRestaurantOrFail(
|
||||
restaurantId,
|
||||
deliveryMethodId,
|
||||
);
|
||||
this.validationService.assertDeliveryMethod(
|
||||
deliveryMethod.method,
|
||||
DeliveryMethodEnum.DeliveryCourier,
|
||||
'Delivery method must be DeliveryCourier',
|
||||
);
|
||||
|
||||
// Find and validate address belongs to user
|
||||
const address = await this.getUserAddressOrFail(setAddressDto.addressId);
|
||||
const address = await this.validationService.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.validationService.validateAddressOwnership(address, userId);
|
||||
|
||||
// ensure address is within restaurant service area
|
||||
await this.assertAddressInsideServiceArea(restaurantId, address.latitude, address.longitude);
|
||||
await this.validationService.assertAddressInsideServiceArea(
|
||||
restaurantId,
|
||||
address.latitude,
|
||||
address.longitude,
|
||||
);
|
||||
|
||||
this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DeliveryCourier);
|
||||
cart.userAddress = {
|
||||
@@ -315,57 +332,6 @@ export class CartService {
|
||||
return this.saveTouchedCart(cart);
|
||||
}
|
||||
|
||||
private async assertAddressInsideServiceArea(
|
||||
restaurantId: string,
|
||||
latitude?: number | null,
|
||||
longitude?: number | null,
|
||||
): Promise<void> {
|
||||
if (latitude === undefined || latitude === null || longitude === undefined || longitude === null) {
|
||||
throw new BadRequestException('Address does not have coordinates');
|
||||
}
|
||||
|
||||
const restaurant = await this.em.findOne(Restaurant, { id: restaurantId });
|
||||
if (!restaurant) {
|
||||
throw new NotFoundException('Restaurant not found');
|
||||
}
|
||||
|
||||
const serviceArea = restaurant.serviceArea;
|
||||
// If no service area is defined, assume service is available everywhere
|
||||
if (!serviceArea || !serviceArea.coordinates || !Array.isArray(serviceArea.coordinates)) return;
|
||||
|
||||
// GeoJSON coordinates: [ [ [lng, lat], ... ] ] -> take first ring
|
||||
const rings = serviceArea.coordinates;
|
||||
if (!rings || rings.length === 0 || !Array.isArray(rings[0])) return;
|
||||
|
||||
const ring = rings[0];
|
||||
const point: [number, number] = [Number(longitude), Number(latitude)];
|
||||
|
||||
// Ensure polygon has the correct tuple typing ([lng, lat] pairs)
|
||||
const polygon: [number, number][] = ring.map(
|
||||
coord => [Number(coord[0] ?? 0), Number(coord[1] ?? 0)] as [number, number],
|
||||
);
|
||||
if (!this.isPointInPolygon(point, polygon)) {
|
||||
throw new BadRequestException('Address is outside the restaurant service area');
|
||||
}
|
||||
}
|
||||
|
||||
// Ray-casting algorithm for point in polygon. Expects polygon as array of [lng, lat]
|
||||
private isPointInPolygon(point: [number, number], polygon: [number, number][]): boolean {
|
||||
const x = point[0];
|
||||
const y = point[1];
|
||||
let inside = false;
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const xi = polygon[i][0],
|
||||
yi = polygon[i][1];
|
||||
const xj = polygon[j][0],
|
||||
yj = polygon[j][1];
|
||||
|
||||
const intersect = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi + Number.EPSILON) + xi;
|
||||
if (intersect) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set payment method for cart
|
||||
*/
|
||||
@@ -376,29 +342,17 @@ export class CartService {
|
||||
): Promise<Cart> {
|
||||
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||
|
||||
const paymentMethod = await this.getEnabledPaymentMethodOrFail(restaurantId, setPaymentMethodDto.paymentMethodId);
|
||||
const paymentMethod = await this.validationService.getEnabledPaymentMethodOrFail(
|
||||
restaurantId,
|
||||
setPaymentMethodDto.paymentMethodId,
|
||||
);
|
||||
if (paymentMethod.method === PaymentMethodEnum.Wallet) {
|
||||
await this.assertWalletHasEnoughBalance(userId, restaurantId, cart.total);
|
||||
await this.validationService.assertWalletHasEnoughBalance(userId, restaurantId, cart.total);
|
||||
}
|
||||
cart.paymentMethodId = setPaymentMethodDto.paymentMethodId;
|
||||
return this.recalculateAndSaveCart(cart);
|
||||
}
|
||||
|
||||
private async assertWalletHasEnoughBalance(userId: string, restaurantId: string, amount: number): Promise<void> {
|
||||
const wallet = await this.em.findOne(UserWallet, {
|
||||
user: { id: userId },
|
||||
restaurant: { id: restaurantId },
|
||||
});
|
||||
console.log('wallet', wallet);
|
||||
if (!wallet) {
|
||||
throw new BadRequestException('User wallet not found');
|
||||
}
|
||||
|
||||
if (wallet.wallet < amount) {
|
||||
throw new BadRequestException('User wallet is not enough');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set delivery method for cart
|
||||
*/
|
||||
@@ -409,7 +363,7 @@ export class CartService {
|
||||
): Promise<Cart> {
|
||||
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||
|
||||
const deliveryMethod = await this.getEnabledDeliveryMethodOrFail(
|
||||
const deliveryMethod = await this.validationService.getEnabledDeliveryMethodOrFail(
|
||||
restaurantId,
|
||||
setDeliveryMethodDto.deliveryMethodId,
|
||||
);
|
||||
@@ -436,12 +390,19 @@ export class CartService {
|
||||
async setTableNumber(userId: string, restaurantId: string, setTableNumberDto: SetTableNumberDto): Promise<Cart> {
|
||||
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||
|
||||
const deliveryMethodId = this.requireDeliveryMethodId(
|
||||
const deliveryMethodId = this.validationService.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');
|
||||
const deliveryMethod = await this.validationService.getDeliveryMethodForRestaurantOrFail(
|
||||
restaurantId,
|
||||
deliveryMethodId,
|
||||
);
|
||||
this.validationService.assertDeliveryMethod(
|
||||
deliveryMethod.method,
|
||||
DeliveryMethodEnum.DineIn,
|
||||
'Delivery method must be DineIn',
|
||||
);
|
||||
|
||||
this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DineIn);
|
||||
cart.tableNumber = setTableNumberDto.tableNumber;
|
||||
@@ -454,17 +415,20 @@ export class CartService {
|
||||
async setCarDelivery(userId: string, restaurantId: string, setCarDeliveryDto: SetCarDeliveryDto): Promise<Cart> {
|
||||
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||
|
||||
const deliveryMethodId = this.requireDeliveryMethodId(
|
||||
const deliveryMethodId = this.validationService.requireDeliveryMethodId(
|
||||
cart,
|
||||
'Delivery method must be set before setting car delivery',
|
||||
);
|
||||
const deliveryMethod = await this.getDeliveryMethodForRestaurantOrFail(restaurantId, deliveryMethodId);
|
||||
this.assertDeliveryMethod(
|
||||
const deliveryMethod = await this.validationService.getDeliveryMethodForRestaurantOrFail(
|
||||
restaurantId,
|
||||
deliveryMethodId,
|
||||
);
|
||||
this.validationService.assertDeliveryMethod(
|
||||
deliveryMethod.method,
|
||||
DeliveryMethodEnum.DeliveryCar,
|
||||
'Delivery method must be DeliveryCar',
|
||||
);
|
||||
const user = await this.getUserOrFail(userId);
|
||||
const user = await this.validationService.getUserOrFail(userId);
|
||||
|
||||
this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DeliveryCar);
|
||||
cart.carAddress = {
|
||||
@@ -477,24 +441,10 @@ export class CartService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate cart totals (including coupon discount and tax)
|
||||
* Clear cart from cache
|
||||
*/
|
||||
private async recalculateCartTotals(cart: Cart): Promise<void> {
|
||||
const { subTotal, itemsDiscount, totalItems } = this.calculateItemsTotals(cart);
|
||||
cart.subTotal = subTotal;
|
||||
cart.itemsDiscount = itemsDiscount;
|
||||
|
||||
const couponDiscount = await this.calculateCouponDiscount(cart, subTotal, itemsDiscount);
|
||||
cart.couponDiscount = couponDiscount;
|
||||
cart.totalDiscount = couponDiscount + itemsDiscount;
|
||||
|
||||
cart.tax = await this.calculateTax(cart.restaurantId, Math.max(0, subTotal - cart.totalDiscount));
|
||||
cart.deliveryFee = await this.calculateDeliveryFee(cart);
|
||||
|
||||
// total = subtotal – totalDiscount + tax + deliveryFee
|
||||
cart.total = Math.max(0, subTotal - cart.totalDiscount) + cart.tax + cart.deliveryFee;
|
||||
cart.totalItems = totalItems;
|
||||
cart.updatedAt = this.nowIso();
|
||||
async clearCart(userId: string, restaurantId: string): Promise<void> {
|
||||
return this.cartRepository.delete(userId, restaurantId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -528,533 +478,27 @@ export class CartService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an object is a Cart
|
||||
* Save cart with updated timestamp
|
||||
*/
|
||||
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'
|
||||
);
|
||||
private async saveTouchedCart(cart: Cart): Promise<Cart> {
|
||||
cart.updatedAt = this.nowIso();
|
||||
await this.cartRepository.save(cart);
|
||||
return cart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cart by restaurant for a user
|
||||
* Recalculate cart totals and save
|
||||
*/
|
||||
private async getCartByRestaurant(userId: string, restaurantId: string): Promise<Cart | null> {
|
||||
const cacheKey = this.getCacheKey(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;
|
||||
private async recalculateAndSaveCart(cart: Cart): Promise<Cart> {
|
||||
await this.calculationService.recalculateCartTotals(cart);
|
||||
await this.cartRepository.save(cart);
|
||||
return cart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save cart to cache
|
||||
* Get current ISO timestamp
|
||||
*/
|
||||
private async saveCart(cart: Cart): Promise<void> {
|
||||
const cacheKey = this.getCacheKey(cart.userId, cart.restaurantId);
|
||||
await this.cacheService.set(cacheKey, JSON.stringify(cart), this.CART_TTL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cart from cache
|
||||
*/
|
||||
async clearCart(userId: string, restaurantId: string): Promise<void> {
|
||||
const cacheKey = this.getCacheKey(userId, restaurantId);
|
||||
return this.cacheService.del(cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key for cart
|
||||
*/
|
||||
private getCacheKey(userId: string, restaurantId: string): string {
|
||||
return `${this.CART_KEY_PREFIX}:${userId}:${restaurantId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate food exists, belongs to restaurant, has inventory, and check stock
|
||||
*/
|
||||
private async validateAndGetFood(foodId: string, restaurantId: string, quantity: number): Promise<Food> {
|
||||
const food = await this.em.findOne(Food, { id: foodId }, { populate: ['restaurant', 'inventory'] });
|
||||
if (!food) {
|
||||
throw new NotFoundException(`Food with ID ${foodId} not found`);
|
||||
}
|
||||
|
||||
if (food.restaurant.id !== restaurantId) {
|
||||
throw new BadRequestException(`Food ${food.title || food.id} does not belong to restaurant ${restaurantId}`);
|
||||
}
|
||||
|
||||
if (!food.inventory) {
|
||||
throw new BadRequestException(`The food ${food.title || food.id} does not have inventory`);
|
||||
}
|
||||
|
||||
this.validateStock(food, quantity);
|
||||
return food;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate stock availability for food
|
||||
*/
|
||||
private validateStock(food: Food, quantity: number): void {
|
||||
const availableStock = food.inventory!.availableStock;
|
||||
if (availableStock < quantity) {
|
||||
throw new BadRequestException(`Insufficient stock for food ${food.title}. Available: ${availableStock}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total price for a cart item
|
||||
*/
|
||||
private calculateItemTotalPrice(unitPrice: number, unitDiscount: number, quantity: number): number {
|
||||
const safeUnitPrice = Number(unitPrice) || 0;
|
||||
const safeUnitDiscount = Math.min(Number(unitDiscount) || 0, safeUnitPrice);
|
||||
const itemTotalPrice = safeUnitPrice * quantity;
|
||||
const itemTotalDiscount = safeUnitDiscount * quantity;
|
||||
return itemTotalPrice - itemTotalDiscount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new cart item from food
|
||||
*/
|
||||
private createCartItem(food: Food, quantity: number): CartItem {
|
||||
const itemPrice = food.price || 0;
|
||||
const itemDiscount = food.discount || 0;
|
||||
|
||||
return {
|
||||
foodId: food.id,
|
||||
foodTitle: food.title,
|
||||
quantity,
|
||||
price: itemPrice,
|
||||
discount: itemDiscount,
|
||||
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 ordersThatUSedTheCouponCount = await this.em.count(Order, {
|
||||
user: { id: userId },
|
||||
couponDetail: { couponId: couponId },
|
||||
status: { $ne: OrderStatus.CANCELED },
|
||||
deletedAt: null,
|
||||
});
|
||||
|
||||
console.log('ordersThatUSedTheCouponCount', ordersThatUSedTheCouponCount);
|
||||
if (ordersThatUSedTheCouponCount >= 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(cart: Cart): Promise<number> {
|
||||
const deliveryMethodId = cart.deliveryMethodId;
|
||||
if (!deliveryMethodId) return 0;
|
||||
|
||||
const deliveryMethod = await this.em.findOne(Delivery, { id: deliveryMethodId }, { populate: ['restaurant'] });
|
||||
if (!deliveryMethod || !deliveryMethod.enabled) return 0;
|
||||
|
||||
// If not distance based, return fixed delivery fee
|
||||
if (deliveryMethod.deliveryFeeType !== DeliveryFeeTypeEnum.DISTANCE_BASED) {
|
||||
return Number(deliveryMethod.deliveryFee) || 0;
|
||||
}
|
||||
|
||||
// For distance based calculation we need restaurant and user coordinates
|
||||
const restaurant = (deliveryMethod as any).restaurant as Restaurant | undefined;
|
||||
const userAddr = cart.userAddress;
|
||||
|
||||
if (!restaurant || restaurant.latitude == null || restaurant.longitude == null) {
|
||||
// fallback to configured fixed fee when coordinates are missing
|
||||
return Number(deliveryMethod.deliveryFee) || 0;
|
||||
}
|
||||
|
||||
if (!userAddr || userAddr.latitude == null || userAddr.longitude == null) {
|
||||
return Number(deliveryMethod.deliveryFee) || 0;
|
||||
}
|
||||
|
||||
const restLat = Number(restaurant.latitude);
|
||||
const restLng = Number(restaurant.longitude);
|
||||
const userLat = Number(userAddr.latitude);
|
||||
const userLng = Number(userAddr.longitude);
|
||||
|
||||
const distanceKm = this.getDistanceKmRounded(restLat, restLng, userLat, userLng);
|
||||
|
||||
// Try to read either possible property names (legacy vs new)
|
||||
const perKm = Number(deliveryMethod.perKilometerFee);
|
||||
const minFee = Number(deliveryMethod.distanceBasedMinCost);
|
||||
|
||||
let fee = 0;
|
||||
if (perKm <= 0) {
|
||||
fee = Number(deliveryMethod.deliveryFee) || 0;
|
||||
} else {
|
||||
fee = distanceKm * perKm;
|
||||
}
|
||||
|
||||
if (minFee > 0 && fee < minFee) fee = minFee;
|
||||
|
||||
return Math.max(0, Number(fee));
|
||||
}
|
||||
|
||||
private toRad(value: number): number {
|
||||
return (value * Math.PI) / 180;
|
||||
}
|
||||
|
||||
private getDistanceKm(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||
const R = 6371; // Earth radius in KM
|
||||
|
||||
const dLat = this.toRad(lat2 - lat1);
|
||||
const dLng = this.toRad(lng2 - lng1);
|
||||
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 + Math.cos(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
||||
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
|
||||
return R * c;
|
||||
}
|
||||
private getDistanceKmRounded(lat1: number, lng1: number, lat2: number, lng2: number, precision = 2): number {
|
||||
const distance = this.getDistanceKm(lat1, lng1, lat2, lng2);
|
||||
return Number(distance.toFixed(precision));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CacheService } from '../../utils/cache.service';
|
||||
import { Cart } from '../interfaces/cart.interface';
|
||||
|
||||
@Injectable()
|
||||
export class CartRepository {
|
||||
private readonly CART_TTL = 3600; // 1 hour in seconds
|
||||
private readonly CART_KEY_PREFIX = 'cart';
|
||||
|
||||
constructor(private readonly cacheService: CacheService) {}
|
||||
|
||||
/**
|
||||
* Get cart by user and restaurant
|
||||
*/
|
||||
async findByUserAndRestaurant(userId: string, restaurantId: string): Promise<Cart | null> {
|
||||
const cacheKey = this.getCacheKey(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
|
||||
*/
|
||||
async save(cart: Cart): Promise<void> {
|
||||
const cacheKey = this.getCacheKey(cart.userId, cart.restaurantId);
|
||||
await this.cacheService.set(cacheKey, JSON.stringify(cart), this.CART_TTL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete cart from cache
|
||||
*/
|
||||
async delete(userId: string, restaurantId: string): Promise<void> {
|
||||
const cacheKey = this.getCacheKey(userId, restaurantId);
|
||||
await this.cacheService.del(cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key for cart
|
||||
*/
|
||||
private getCacheKey(userId: string, restaurantId: string): string {
|
||||
return `${this.CART_KEY_PREFIX}:${userId}:${restaurantId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Utility class for geographic calculations
|
||||
*/
|
||||
export class GeographicUtils {
|
||||
/**
|
||||
* Check if a point is inside a polygon using ray-casting algorithm
|
||||
* @param point - [longitude, latitude]
|
||||
* @param polygon - Array of [longitude, latitude] pairs
|
||||
*/
|
||||
static isPointInPolygon(point: [number, number], polygon: [number, number][]): boolean {
|
||||
const x = point[0];
|
||||
const y = point[1];
|
||||
let inside = false;
|
||||
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const xi = polygon[i][0];
|
||||
const yi = polygon[i][1];
|
||||
const xj = polygon[j][0];
|
||||
const yj = polygon[j][1];
|
||||
|
||||
const intersect =
|
||||
yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi + Number.EPSILON) + xi;
|
||||
if (intersect) inside = !inside;
|
||||
}
|
||||
|
||||
return inside;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert degrees to radians
|
||||
*/
|
||||
private static toRad(value: number): number {
|
||||
return (value * Math.PI) / 180;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate distance between two points in kilometers using Haversine formula
|
||||
*/
|
||||
static getDistanceKm(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||
const R = 6371; // Earth radius in KM
|
||||
|
||||
const dLat = this.toRad(lat2 - lat1);
|
||||
const dLng = this.toRad(lng2 - lng1);
|
||||
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
||||
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
|
||||
return R * c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate distance between two points in kilometers (rounded)
|
||||
*/
|
||||
static getDistanceKmRounded(
|
||||
lat1: number,
|
||||
lng1: number,
|
||||
lat2: number,
|
||||
lng2: number,
|
||||
precision = 2,
|
||||
): number {
|
||||
const distance = this.getDistanceKm(lat1, lng1, lat2, lng2);
|
||||
return Number(distance.toFixed(precision));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user