From 6485e51dce60d0ed1381a64db5739a5890f562b4 Mon Sep 17 00:00:00 2001 From: morteza-mortezai Date: Sat, 20 Dec 2025 19:33:09 +0330 Subject: [PATCH] refactor cart --- src/modules/cart/cart.module.ts | 27 +- .../cart/controllers/cart.controller.ts | 66 -- .../providers/cart-calculation.service.ts | 244 ++++++ .../cart/providers/cart-item.service.ts | 105 +++ .../cart/providers/cart-validation.service.ts | 295 +++++++ src/modules/cart/providers/cart.service.ts | 782 +++--------------- .../cart/repositories/cart.repository.ts | 81 ++ src/modules/cart/utils/geographic.utils.ts | 68 ++ 8 files changed, 931 insertions(+), 737 deletions(-) create mode 100644 src/modules/cart/providers/cart-calculation.service.ts create mode 100644 src/modules/cart/providers/cart-item.service.ts create mode 100644 src/modules/cart/providers/cart-validation.service.ts create mode 100644 src/modules/cart/repositories/cart.repository.ts create mode 100644 src/modules/cart/utils/geographic.utils.ts diff --git a/src/modules/cart/cart.module.ts b/src/modules/cart/cart.module.ts index 9936574..00005fb 100644 --- a/src/modules/cart/cart.module.ts +++ b/src/modules/cart/cart.module.ts @@ -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 {} diff --git a/src/modules/cart/controllers/cart.controller.ts b/src/modules/cart/controllers/cart.controller.ts index 03c0af0..5657275 100644 --- a/src/modules/cart/controllers/cart.controller.ts +++ b/src/modules/cart/controllers/cart.controller.ts @@ -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) diff --git a/src/modules/cart/providers/cart-calculation.service.ts b/src/modules/cart/providers/cart-calculation.service.ts new file mode 100644 index 0000000..d29ab20 --- /dev/null +++ b/src/modules/cart/providers/cart-calculation.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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> { + 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(); + } +} + diff --git a/src/modules/cart/providers/cart-item.service.ts b/src/modules/cart/providers/cart-item.service.ts new file mode 100644 index 0000000..6d2c710 --- /dev/null +++ b/src/modules/cart/providers/cart-item.service.ts @@ -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 { + 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 { + 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); + } +} + diff --git a/src/modules/cart/providers/cart-validation.service.ts b/src/modules/cart/providers/cart-validation.service.ts new file mode 100644 index 0000000..8a26fb4 --- /dev/null +++ b/src/modules/cart/providers/cart-validation.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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> { + 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])); + } +} + diff --git a/src/modules/cart/providers/cart.service.ts b/src/modules/cart/providers/cart.service.ts index 7c1dff0..74f55b9 100644 --- a/src/modules/cart/providers/cart.service.ts +++ b/src/modules/cart/providers/cart.service.ts @@ -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 { 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 { - 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 { - 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 { 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 { 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 { 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 { 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 { - 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 { 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 { - 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 { 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 { 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 { 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 { - 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 { + 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; - 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.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 { - const cacheKey = this.getCacheKey(userId, restaurantId); - const cachedCart = await this.cacheService.get(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 { + 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 { - 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 { - 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 { - 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.updatedAt = this.nowIso(); - await this.saveCart(cart); - return cart; - } - - private async recalculateAndSaveCart(cart: Cart): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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> { - 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 { - 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 { - 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 { - 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)); - } } diff --git a/src/modules/cart/repositories/cart.repository.ts b/src/modules/cart/repositories/cart.repository.ts new file mode 100644 index 0000000..4c1a6f4 --- /dev/null +++ b/src/modules/cart/repositories/cart.repository.ts @@ -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 { + const cacheKey = this.getCacheKey(userId, restaurantId); + const cachedCart = await this.cacheService.get(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 { + 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 { + 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; + 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' + ); + } +} + diff --git a/src/modules/cart/utils/geographic.utils.ts b/src/modules/cart/utils/geographic.utils.ts new file mode 100644 index 0000000..8b2d888 --- /dev/null +++ b/src/modules/cart/utils/geographic.utils.ts @@ -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)); + } +} +