refactor cart

This commit is contained in:
2025-12-20 19:33:09 +03:30
parent 6bfef28886
commit 6485e51dce
8 changed files with 931 additions and 737 deletions
+25 -2
View File
@@ -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]));
}
}
+113 -669
View File
@@ -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));
}
}