diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 4134e85..27315bd 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -636,6 +636,7 @@ export const enum CouponMessage { COUPON_DELETED = 'کوپن با موفقیت حذف شد', COUPON_VALID = 'کوپن معتبر است', COUPON_INVALID = 'کوپن نامعتبر است', + COUPON_RESTRICTED_TO_USER = 'کوپن فقط برای کاربر مشخصی معتبر است', } export const enum ReviewMessage { diff --git a/src/modules/cart/controllers/cart.controller.ts b/src/modules/cart/controllers/cart.controller.ts index e30fd9d..9ad04ee 100644 --- a/src/modules/cart/controllers/cart.controller.ts +++ b/src/modules/cart/controllers/cart.controller.ts @@ -1,18 +1,27 @@ import { Controller, Get, Post, Body, Patch, Delete, UseGuards, Param } from '@nestjs/common'; import { CartService } from '../providers/cart.service'; import { BulkAddItemsToCartDto } from '../dto/bulk-add-items.dto'; -import { UpdateItemQuantityDto } from '../dto/update-item.dto'; import { ApplyCouponDto } from '../dto/apply-coupon.dto'; import { SetAddressDto } from '../dto/set-address.dto'; import { SetPaymentMethodDto } from '../dto/set-payment-method.dto'; import { 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 { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiParam, ApiHeader } from '@nestjs/swagger'; import { AuthGuard } from '../../auth/guards/auth.guard'; import { UserId } from 'src/common/decorators/user-id.decorator'; import { RestId } from 'src/common/decorators/rest-id.decorator'; +const API_HEADER_SLUG = { + name: 'X-Slug', + required: true, + schema: { + type: 'string', + default: 'zhivan', + }, +}; + @UseGuards(AuthGuard) @ApiBearerAuth() @ApiTags('cart') @@ -22,43 +31,22 @@ export class CartController { @Get() @ApiOperation({ summary: 'Get cart for current user and restaurant' }) - @ApiHeader({ - name: 'X-Slug', - required: true, - schema: { - type: 'string', - default: 'zhivan', - }, - }) + @ApiHeader(API_HEADER_SLUG) async findOne(@UserId() userId: string, @RestId() restaurantId: string) { return this.cartService.getOrCreateCart(userId, restaurantId); } @Post('items/:foodId/increment') @ApiOperation({ summary: 'Increment item quantity in cart by 1' }) - @ApiHeader({ - name: 'X-Slug', - required: true, - schema: { - type: 'string', - default: 'zhivan', - }, - }) + @ApiHeader(API_HEADER_SLUG) @ApiParam({ name: 'foodId', description: 'Food ID to increment in cart' }) async incrementItem(@UserId() userId: string, @RestId() restaurantId: string, @Param('foodId') foodId: string) { - return this.cartService.incrementItem(userId, restaurantId, foodId); + return this.cartService.incrementItem(userId, restaurantId, foodId, 1); } @Post('items/:foodId/decrement') @ApiOperation({ summary: 'Decrement item quantity in cart by 1 (removes item if quantity reaches 0)' }) - @ApiHeader({ - name: 'X-Slug', - required: true, - schema: { - type: 'string', - default: 'zhivan', - }, - }) + @ApiHeader(API_HEADER_SLUG) @ApiParam({ name: 'foodId', description: 'Food ID to decrement in cart' }) async decrementItem(@UserId() userId: string, @RestId() restaurantId: string, @Param('foodId') foodId: string) { return this.cartService.decrementItem(userId, restaurantId, foodId); @@ -66,14 +54,7 @@ export class CartController { @Post('items/bulk') @ApiOperation({ summary: 'Bulk add items to cart (increments quantity if items exist)' }) - @ApiHeader({ - name: 'X-Slug', - required: true, - schema: { - type: 'string', - default: 'zhivan', - }, - }) + @ApiHeader(API_HEADER_SLUG) @ApiBody({ type: BulkAddItemsToCartDto }) bulkAddItems( @UserId() userId: string, @@ -83,37 +64,9 @@ export class CartController { return this.cartService.bulkAddItems(userId, restaurantId, bulkAddItemsDto); } - @Patch('items/:foodId') - @ApiOperation({ summary: 'Update item quantity in cart' }) - @ApiHeader({ - name: 'X-Slug', - required: true, - schema: { - type: 'string', - default: 'zhivan', - }, - }) - @ApiParam({ name: 'foodId', description: 'Food ID in the cart' }) - @ApiBody({ type: UpdateItemQuantityDto }) - async updateItemQuantity( - @UserId() userId: string, - @RestId() restaurantId: string, - @Param('foodId') foodId: string, - @Body() updateItemDto: UpdateItemQuantityDto, - ) { - return this.cartService.updateItemQuantity(userId, restaurantId, foodId, updateItemDto); - } - @Delete('items/:foodId') @ApiOperation({ summary: 'Remove item from cart' }) - @ApiHeader({ - name: 'X-Slug', - required: true, - schema: { - type: 'string', - default: 'zhivan', - }, - }) + @ApiHeader(API_HEADER_SLUG) @ApiParam({ name: 'foodId', description: 'Food ID in the cart' }) async removeItem(@UserId() userId: string, @RestId() restaurantId: string, @Param('foodId') foodId: string) { return this.cartService.removeItem(userId, restaurantId, foodId); @@ -121,28 +74,14 @@ export class CartController { @Delete() @ApiOperation({ summary: 'Clear entire cart' }) - @ApiHeader({ - name: 'X-Slug', - required: true, - schema: { - type: 'string', - default: 'zhivan', - }, - }) + @ApiHeader(API_HEADER_SLUG) async clearCart(@UserId() userId: string, @RestId() restaurantId: string) { return this.cartService.clearCart(userId, restaurantId); } @Post('apply-coupon') @ApiOperation({ summary: 'Apply coupon to cart' }) - @ApiHeader({ - name: 'X-Slug', - required: true, - schema: { - type: 'string', - default: 'zhivan', - }, - }) + @ApiHeader(API_HEADER_SLUG) @ApiBody({ type: ApplyCouponDto }) async applyCoupon(@UserId() userId: string, @RestId() restaurantId: string, @Body() applyCouponDto: ApplyCouponDto) { return this.cartService.applyCoupon(userId, restaurantId, applyCouponDto); @@ -150,28 +89,14 @@ export class CartController { @Delete('coupon') @ApiOperation({ summary: 'Remove coupon from cart' }) - @ApiHeader({ - name: 'X-Slug', - required: true, - schema: { - type: 'string', - default: 'zhivan', - }, - }) + @ApiHeader(API_HEADER_SLUG) async removeCoupon(@UserId() userId: string, @RestId() restaurantId: string) { return this.cartService.removeCoupon(userId, restaurantId); } @Patch('address') @ApiOperation({ summary: 'Set address for cart' }) - @ApiHeader({ - name: 'X-Slug', - required: true, - schema: { - type: 'string', - default: 'zhivan', - }, - }) + @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); @@ -179,14 +104,7 @@ export class CartController { @Patch('payment-method') @ApiOperation({ summary: 'Set payment method for cart' }) - @ApiHeader({ - name: 'X-Slug', - required: true, - schema: { - type: 'string', - default: 'zhivan', - }, - }) + @ApiHeader(API_HEADER_SLUG) @ApiBody({ type: SetPaymentMethodDto }) async setPaymentMethod( @UserId() userId: string, @@ -198,14 +116,7 @@ export class CartController { @Patch('delivery-method') @ApiOperation({ summary: 'Set delivery method for cart' }) - @ApiHeader({ - name: 'X-Slug', - required: true, - schema: { - type: 'string', - default: 'zhivan', - }, - }) + @ApiHeader(API_HEADER_SLUG) @ApiBody({ type: SetDeliveryMethodDto }) setDeliveryMethod( @UserId() userId: string, @@ -217,14 +128,7 @@ export class CartController { @Patch('description') @ApiOperation({ summary: 'Set description for cart' }) - @ApiHeader({ - name: 'X-Slug', - required: true, - schema: { - type: 'string', - default: 'zhivan', - }, - }) + @ApiHeader(API_HEADER_SLUG) @ApiBody({ type: SetDescriptionDto }) setDescription( @UserId() userId: string, @@ -236,14 +140,7 @@ export class CartController { @Patch('table-number') @ApiOperation({ summary: 'Set table number for cart (required when delivery method is DineIn)' }) - @ApiHeader({ - name: 'X-Slug', - required: true, - schema: { - type: 'string', - default: 'zhivan', - }, - }) + @ApiHeader(API_HEADER_SLUG) @ApiBody({ type: SetTableNumberDto }) setTableNumber( @UserId() userId: string, @@ -252,4 +149,16 @@ export class CartController { ) { 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); + } } diff --git a/src/modules/cart/dto/add-item.dto.ts b/src/modules/cart/dto/add-item.dto.ts deleted file mode 100644 index 154845b..0000000 --- a/src/modules/cart/dto/add-item.dto.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsNumber, IsString, Min } from 'class-validator'; - -export class AddItemToCartDto { - @ApiProperty({ description: 'Quantity of the food item', example: 1, minimum: 1 }) - @IsNotEmpty() - @IsNumber() - @Min(1) - quantity!: number; - - @ApiProperty({ description: 'Food ID' }) - @IsNotEmpty() - @IsString() - foodId!: string; -} diff --git a/src/modules/cart/dto/bulk-add-items.dto.ts b/src/modules/cart/dto/bulk-add-items.dto.ts index 783688b..f54ba78 100644 --- a/src/modules/cart/dto/bulk-add-items.dto.ts +++ b/src/modules/cart/dto/bulk-add-items.dto.ts @@ -1,7 +1,19 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsArray, ValidateNested, ArrayMinSize } from 'class-validator'; +import { IsNotEmpty, IsArray, ValidateNested, ArrayMinSize, IsNumber, Min, IsString } from 'class-validator'; import { Type } from 'class-transformer'; -import { AddItemToCartDto } from './add-item.dto'; + +class AddItemToCartDto { + @ApiProperty({ description: 'Quantity of the food item', example: 1, minimum: 1 }) + @IsNotEmpty() + @IsNumber() + @Min(1) + quantity!: number; + + @ApiProperty({ description: 'Food ID' }) + @IsNotEmpty() + @IsString() + foodId!: string; +} export class BulkAddItemsToCartDto { @ApiProperty({ diff --git a/src/modules/cart/dto/set-car-delivery.dto.ts b/src/modules/cart/dto/set-car-delivery.dto.ts new file mode 100644 index 0000000..ea3b6d9 --- /dev/null +++ b/src/modules/cart/dto/set-car-delivery.dto.ts @@ -0,0 +1,41 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, IsNumber, IsLatitude, IsLongitude } from 'class-validator'; + +export class SetCarDeliveryDto { + @ApiProperty({ description: 'Car model', example: 'Toyota Camry' }) + @IsNotEmpty() + @IsString() + carModel!: string; + + @ApiProperty({ description: 'Car color', example: 'White' }) + @IsNotEmpty() + @IsString() + carColor!: string; + + @ApiProperty({ description: 'License plate number', example: '12ABC345' }) + @IsNotEmpty() + @IsString() + plateNumber!: string; + + @ApiProperty({ description: 'Delivery address', example: '123 Main Street, City' }) + @IsNotEmpty() + @IsString() + address!: string; + + @ApiProperty({ description: 'Latitude coordinate', example: 35.6892 }) + @IsNotEmpty() + @IsNumber() + @IsLatitude() + latitude!: number; + + @ApiProperty({ description: 'Longitude coordinate', example: 51.389 }) + @IsNotEmpty() + @IsNumber() + @IsLongitude() + longitude!: number; + + @ApiProperty({ description: 'Full name of the recipient', example: 'John Doe' }) + @IsNotEmpty() + @IsString() + fullName!: string; +} diff --git a/src/modules/cart/dto/update-item.dto.ts b/src/modules/cart/dto/update-item.dto.ts deleted file mode 100644 index 2970604..0000000 --- a/src/modules/cart/dto/update-item.dto.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsNumber, Min } from 'class-validator'; - -export class UpdateItemQuantityDto { - @ApiProperty({ description: 'Quantity of the food item', example: 2, minimum: 1 }) - @IsNotEmpty() - @IsNumber() - @Min(1) - quantity!: number; -} diff --git a/src/modules/cart/interfaces/cart.interface.ts b/src/modules/cart/interfaces/cart.interface.ts index a4390ad..e24b3ae 100644 --- a/src/modules/cart/interfaces/cart.interface.ts +++ b/src/modules/cart/interfaces/cart.interface.ts @@ -16,7 +16,6 @@ export interface Cart { items: CartItem[]; coupon?: OrderCouponDetail; - addressId?: string; paymentMethodId?: string; deliveryMethodId?: string; description?: string; @@ -30,6 +29,28 @@ export interface Cart { totalDiscount: number; total: number; + carAddress?: { + carModel: string; + carColor: string; + plateNumber: string; + address?: string; + latitude?: number; + longitude?: number; + fullName: string; + phone: string; + } | null; + + userAddress?: { + address?: string; + latitude?: number; + longitude?: number; + city: string; + province: string; + postalCode: string; + fullName: string; + phone: string; + } | null; + totalItems: number; createdAt: string; updatedAt: string; diff --git a/src/modules/cart/providers/cart.service.ts b/src/modules/cart/providers/cart.service.ts index 442e626..51f90ec 100644 --- a/src/modules/cart/providers/cart.service.ts +++ b/src/modules/cart/providers/cart.service.ts @@ -3,15 +3,14 @@ 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 { AddItemToCartDto } from '../dto/add-item.dto'; import { BulkAddItemsToCartDto } from '../dto/bulk-add-items.dto'; -import { UpdateItemQuantityDto } from '../dto/update-item.dto'; import { ApplyCouponDto } from '../dto/apply-coupon.dto'; import { SetAddressDto } from '../dto/set-address.dto'; import { SetPaymentMethodDto } from '../dto/set-payment-method.dto'; import { 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'; @@ -82,92 +81,35 @@ export class CartService { return cart; } - async findOne2(userId: string, restaurantId: string): Promise { - const cart = await this.getCartByRestaurant(userId, restaurantId); - return cart; - } - /** - * Add item to cart (increment quantity if item exists, otherwise create new) + * Increment item quantity in cart by 1 */ - async addItem(userId: string, restaurantId: string, foodId: string, addItemDto: AddItemToCartDto): Promise { + 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); - // Find food - const food = await this.em.findOne(Food, { id: foodId }, { populate: ['restaurant', 'inventory'] }); - if (!food) { - throw new NotFoundException(`Food with ID ${foodId} not found`); - } - - // Check if food belongs to the restaurant - if (food.restaurant.id !== restaurantId) { - throw new BadRequestException(`Food ${food.title || food.id} does not belong to restaurant ${restaurantId}`); - } - - // Check stock availability - const availableStock = food.inventory?.availableStock ?? 0; - if (availableStock < addItemDto.quantity) { - throw new BadRequestException(`Insufficient stock for food ${food.title}. Available: ${availableStock}`); - } - - // Check if item already exists in cart const existingItemIndex = cart.items.findIndex(item => item.foodId === foodId); if (existingItemIndex >= 0) { - // Update existing item quantity const existingItem = cart.items[existingItemIndex]; - const newQuantity = existingItem.quantity + addItemDto.quantity; - - // Check stock for new total quantity - if (availableStock < newQuantity) { - throw new BadRequestException(`Insufficient stock for food ${food.title}. Available: ${availableStock}`); - } - - const itemPrice = food.price || 0; - const itemDiscount = food.discount || 0; - const itemTotalPrice = itemPrice * newQuantity; - const itemTotalDiscount = itemDiscount * newQuantity; - const itemFinalPrice = itemTotalPrice - itemTotalDiscount; + const newQuantity = existingItem.quantity + quantity; + this.validateStock(food, newQuantity); cart.items[existingItemIndex] = { ...existingItem, quantity: newQuantity, - totalPrice: itemFinalPrice, + totalPrice: this.calculateItemTotalPrice(food, newQuantity), }; } else { - // Create new item - const itemPrice = food.price || 0; - const itemDiscount = food.discount || 0; - const itemTotalPrice = itemPrice * addItemDto.quantity; - const itemTotalDiscount = itemDiscount * addItemDto.quantity; - const itemFinalPrice = itemTotalPrice - itemTotalDiscount; - - const cartItem: CartItem = { - foodId: food.id, - foodTitle: food.title, - quantity: addItemDto.quantity, - price: itemPrice, - discount: itemDiscount, - totalPrice: itemFinalPrice, - }; - - cart.items.push(cartItem); + cart.items.push(this.createCartItem(food, quantity)); } - // Recalculate cart totals await this.recalculateCartTotals(cart); await this.saveCart(cart); return cart; } - /** - * Increment item quantity in cart by 1 - */ - async incrementItem(userId: string, restaurantId: string, foodId: string): Promise { - return this.addItem(userId, restaurantId, foodId, { foodId, quantity: 1 }); - } - /** * Decrement item quantity in cart by 1 (removes item if quantity reaches 0) */ @@ -186,22 +128,15 @@ export class CartService { // Remove item if quantity becomes 0 or less cart.items.splice(itemIndex, 1); } else { - // Update item quantity and recalculate price const food = await this.em.findOne(Food, { id: foodId }); if (!food) { throw new NotFoundException(`Food with ID ${foodId} not found`); } - const itemPrice = food.price || 0; - const itemDiscount = food.discount || 0; - const itemTotalPrice = itemPrice * newQuantity; - const itemTotalDiscount = itemDiscount * newQuantity; - const itemFinalPrice = itemTotalPrice - itemTotalDiscount; - cart.items[itemIndex] = { ...existingItem, quantity: newQuantity, - totalPrice: itemFinalPrice, + totalPrice: this.calculateItemTotalPrice(food, newQuantity), }; } @@ -218,121 +153,25 @@ export class CartService { async bulkAddItems(userId: string, restaurantId: string, bulkAddItemsDto: BulkAddItemsToCartDto): Promise { const cart = await this.getOrCreateCart(userId, restaurantId); - // Process each item for (const addItemDto of bulkAddItemsDto.items) { - // Find food - const food = await this.em.findOne(Food, { id: addItemDto.foodId }, { populate: ['restaurant', 'inventory'] }); - if (!food) { - throw new NotFoundException(`Food with ID ${addItemDto.foodId} not found`); - } - - // Check if food belongs to the restaurant - if (food.restaurant.id !== restaurantId) { - throw new BadRequestException(`Food ${food.title || food.id} does not belong to restaurant ${restaurantId}`); - } - - // Check stock availability - const availableStock = food.inventory?.availableStock ?? 0; - if (availableStock < addItemDto.quantity) { - throw new BadRequestException(`Insufficient stock for food ${food.title}. Available: ${availableStock}`); - } - - // Check if item already exists in cart + const food = await this.validateAndGetFood(addItemDto.foodId, restaurantId, addItemDto.quantity); const existingItemIndex = cart.items.findIndex(item => item.foodId === addItemDto.foodId); if (existingItemIndex >= 0) { - // Update existing item quantity const existingItem = cart.items[existingItemIndex]; const newQuantity = existingItem.quantity + addItemDto.quantity; - // Check stock for new total quantity - if (availableStock < newQuantity) { - throw new BadRequestException(`Insufficient stock for food ${food.title}. Available: ${availableStock}`); - } - - const itemPrice = food.price || 0; - const itemDiscount = food.discount || 0; - const itemTotalPrice = itemPrice * newQuantity; - const itemTotalDiscount = itemDiscount * newQuantity; - const itemFinalPrice = itemTotalPrice - itemTotalDiscount; - + this.validateStock(food, newQuantity); cart.items[existingItemIndex] = { ...existingItem, quantity: newQuantity, - totalPrice: itemFinalPrice, + totalPrice: this.calculateItemTotalPrice(food, newQuantity), }; } else { - // Create new item - const itemPrice = food.price || 0; - const itemDiscount = food.discount || 0; - const itemTotalPrice = itemPrice * addItemDto.quantity; - const itemTotalDiscount = itemDiscount * addItemDto.quantity; - const itemFinalPrice = itemTotalPrice - itemTotalDiscount; - - const cartItem: CartItem = { - foodId: food.id, - foodTitle: food.title, - quantity: addItemDto.quantity, - price: itemPrice, - discount: itemDiscount, - totalPrice: itemFinalPrice, - }; - - cart.items.push(cartItem); + cart.items.push(this.createCartItem(food, addItemDto.quantity)); } } - // Recalculate cart totals once after all items are added - await this.recalculateCartTotals(cart); - await this.saveCart(cart); - - return cart; - } - - /** - * Update item quantity in cart - */ - async updateItemQuantity( - userId: string, - restaurantId: string, - foodId: string, - updateItemDto: UpdateItemQuantityDto, - ): Promise { - const cart = await this.findOneOrFail(userId, restaurantId); - - const itemIndex = cart.items.findIndex(item => item.foodId === foodId); - if (itemIndex < 0) { - throw new NotFoundException(`Item with food ID ${foodId} not found in cart`); - } - - const item = cart.items[itemIndex]; - - // Find food to check stock - const food = await this.em.findOne(Food, { id: item.foodId }, { populate: ['restaurant', 'inventory'] }); - if (!food) { - throw new NotFoundException(`Food with ID ${item.foodId} not found`); - } - - // Check stock availability - const availableStock = food.inventory?.availableStock ?? 0; - if (availableStock < updateItemDto.quantity) { - throw new BadRequestException(`Insufficient stock for food ${food.title}. Available: ${availableStock}`); - } - - // Update quantity - const itemPrice = food.price || 0; - const itemDiscount = food.discount || 0; - const itemTotalPrice = itemPrice * updateItemDto.quantity; - const itemTotalDiscount = itemDiscount * updateItemDto.quantity; - const itemFinalPrice = itemTotalPrice - itemTotalDiscount; - - cart.items[itemIndex] = { - ...item, - quantity: updateItemDto.quantity, - totalPrice: itemFinalPrice, - }; - - // Recalculate cart totals await this.recalculateCartTotals(cart); await this.saveCart(cart); @@ -366,18 +205,17 @@ export class CartService { async applyCoupon(userId: string, restaurantId: string, applyCouponDto: ApplyCouponDto): Promise { const cart = await this.findOneOrFail(userId, restaurantId); - // Calculate current order amount (subtotal after item discounts) for coupon validation - // const currentSubTotal = cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0); - // const currentItemsDiscount = cart.items.reduce((sum, item) => sum + item.discount * item.quantity, 0); - // const orderAmount = currentSubTotal - currentItemsDiscount; - const orderAmount = cart.subTotal - cart.itemsDiscount; - + const user = await this.em.findOne(User, { id: userId }); + if (!user) { + throw new NotFoundException(`User with ID ${userId} not found`); + } // check if coupon is valid and belong to the restaurant const { valid, coupon, message } = await this.couponService.validateCoupon( applyCouponDto.code, restaurantId, Number(orderAmount), + user.phone, ); if (!valid) { @@ -388,20 +226,6 @@ export class CartService { throw new BadRequestException('Coupon not found'); } - // Validate user phone if coupon is restricted to a specific user - if (coupon.userPhone) { - const user = await this.em.findOne(User, { id: userId }); - if (!user) { - throw new NotFoundException('User not found'); - } - - if (user.phone !== coupon.userPhone) { - throw new BadRequestException( - 'This coupon is only available for a specific user and cannot be applied to your account.', - ); - } - } - // Check maxUsesPerUser limit by counting how many orders this user has with this coupon if (coupon.maxUsesPerUser) { // Use native query for JSONB field access with proper parameter binding @@ -510,7 +334,14 @@ export class CartService { throw new BadRequestException('Address does not belong to the current user'); } - cart.addressId = setAddressDto.addressId; + cart.userAddress = { + address: address.address, + city: address.city, + province: address.province || '', + postalCode: address.postalCode || '', + fullName: address.user.firstName + ' ' + address.user.lastName || '', + phone: address.user.phone, + }; cart.updatedAt = new Date().toISOString(); await this.saveCart(cart); @@ -618,18 +449,20 @@ export class CartService { async setTableNumber(userId: string, restaurantId: string, setTableNumberDto: SetTableNumberDto): Promise { const cart = await this.findOneOrFail(userId, restaurantId); - // If delivery method is set, validate that table number is required for DineIn - if (cart.deliveryMethodId) { - const deliveryMethod = await this.em.findOne(Delivery, { - id: cart.deliveryMethodId, - restaurant: { id: restaurantId }, - }); - - if (deliveryMethod && deliveryMethod.method === DeliveryMethodEnum.DineIn) { - if (!setTableNumberDto.tableNumber || setTableNumberDto.tableNumber.trim() === '') { - throw new BadRequestException('Table number is required when delivery method is DineIn'); - } - } + if (!cart.deliveryMethodId) { + throw new BadRequestException('Delivery method must be set before setting table number'); + } + const deliveryMethod = await this.em.findOne(Delivery, { + id: cart.deliveryMethodId, + restaurant: { id: restaurantId }, + }); + if (!deliveryMethod) { + throw new NotFoundException( + `Delivery method with ID ${cart.deliveryMethodId} not found for restaurant ${restaurantId}`, + ); + } + if (deliveryMethod.method !== DeliveryMethodEnum.DineIn) { + throw new BadRequestException('Delivery method must be DineIn'); } cart.tableNumber = setTableNumberDto.tableNumber; @@ -640,6 +473,49 @@ export class CartService { return cart; } + /** + * Set car delivery for cart + */ + async setCarDelivery(userId: string, restaurantId: string, setCarDeliveryDto: SetCarDeliveryDto): Promise { + const cart = await this.findOneOrFail(userId, restaurantId); + + if (!cart.deliveryMethodId) + throw new BadRequestException('Delivery method must be set before setting car delivery'); + const deliveryMethod = await this.em.findOne(Delivery, { + id: cart.deliveryMethodId, + restaurant: { id: restaurantId }, + }); + + if (!deliveryMethod) { + throw new NotFoundException( + `Delivery method with ID ${cart.deliveryMethodId} not found for restaurant ${restaurantId}`, + ); + } + if (deliveryMethod.method !== DeliveryMethodEnum.DeliveryCar) { + throw new BadRequestException('Delivery method must be DeliveryCar'); + } + const user = await this.em.findOne(User, { id: userId }); + if (!user) { + throw new NotFoundException(`User with ID ${userId} not found`); + } + + cart.carAddress = { + carModel: setCarDeliveryDto.carModel, + carColor: setCarDeliveryDto.carColor, + plateNumber: setCarDeliveryDto.plateNumber, + address: setCarDeliveryDto.address, + latitude: setCarDeliveryDto.latitude, + longitude: setCarDeliveryDto.longitude, + fullName: setCarDeliveryDto.fullName, + phone: user.phone, + }; + cart.updatedAt = new Date().toISOString(); + + await this.saveCart(cart); + + return cart; + } + /** * Recalculate cart totals (including coupon discount and tax) */ @@ -778,7 +654,7 @@ export class CartService { * Get cart by restaurant for a user */ private async getCartByRestaurant(userId: string, restaurantId: string): Promise { - const cacheKey = `${this.CART_KEY_PREFIX}:${userId}:${restaurantId}`; + const cacheKey = this.getCacheKey(userId, restaurantId); const cachedCart = await this.cacheService.get(cacheKey); if (cachedCart) { @@ -799,12 +675,81 @@ export class CartService { * Save cart to cache */ private async saveCart(cart: Cart): Promise { - const cacheKey = `${this.CART_KEY_PREFIX}:${cart.userId}:${cart.restaurantId}`; + 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.CART_KEY_PREFIX}:${userId}:${restaurantId}`; + 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(food: Food, quantity: number): number { + const itemPrice = food.price || 0; + const itemDiscount = food.discount || 0; + const itemTotalPrice = itemPrice * quantity; + const itemTotalDiscount = itemDiscount * 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(food, quantity), + }; + } } diff --git a/src/modules/coupons/providers/coupon.service.ts b/src/modules/coupons/providers/coupon.service.ts index 4f00ed2..897c3e6 100644 --- a/src/modules/coupons/providers/coupon.service.ts +++ b/src/modules/coupons/providers/coupon.service.ts @@ -162,6 +162,7 @@ export class CouponService { code: string, restId: string, orderAmount: number, + userPhone: string, ): Promise<{ valid: boolean; coupon?: Coupon; @@ -179,6 +180,14 @@ export class CouponService { }; } + if (coupon.userPhone) { + if (userPhone !== coupon.userPhone) { + return { + valid: false, + message: CouponMessage.COUPON_RESTRICTED_TO_USER, + }; + } + } // Check if coupon is active if (!coupon.isActive) { return { diff --git a/src/modules/orders/entities/order.entity.ts b/src/modules/orders/entities/order.entity.ts index 7e95520..c2ed379 100644 --- a/src/modules/orders/entities/order.entity.ts +++ b/src/modules/orders/entities/order.entity.ts @@ -16,10 +16,10 @@ import { PaymentStatusEnum } from '../../payments/interface/payment'; import { OrderCouponDetail, OrderStatus } from '../interface/order-status'; import { User } from '../../users/entities/user.entity'; import { Restaurant } from '../../restaurants/entities/restaurant.entity'; -import { UserAddress } from '../../users/entities/user-address.entity'; import { PaymentMethod } from '../../payments/entities/payment-method.entity'; import { OrderItem } from './order-item.entity'; import { Delivery } from '../../delivery/entities/delivery.entity'; +import { OrderUserAddress, OrderCarAddress } from '../interface/order-status'; @Entity({ tableName: 'orders' }) @Unique({ properties: ['restaurant', 'orderNumber'] }) @@ -45,16 +45,10 @@ export class Order extends BaseEntity { items = new Collection(this); @Property({ type: 'json', nullable: true }) - address?: { - address: string; - latitude: number; - longitude: number; - city: string; - province: string; - postalCode: string; - fullName: string; - phone: string; - }; + userAddress?: OrderUserAddress | null; + // for car delivery + @Property({ type: 'json', nullable: true }) + carAddress?: OrderCarAddress | null; @ManyToOne(() => PaymentMethod) paymentMethod: PaymentMethod; diff --git a/src/modules/orders/interface/order-status.ts b/src/modules/orders/interface/order-status.ts index ce8f73e..ff2b6ef 100644 --- a/src/modules/orders/interface/order-status.ts +++ b/src/modules/orders/interface/order-status.ts @@ -1,28 +1,27 @@ import type { CouponType } from 'src/modules/coupons/interface/coupon'; -// export enum OrderStatus { -// // Initial status -// Pending = 'pending', +export interface OrderUserAddress { + address?: string; + latitude?: number; + longitude?: number; + city: string; + province: string; + postalCode?: string; + fullName: string; + phone: string; +} -// // Cancellation statuses -// CancelledBySystem = 'cancelledBySystem', -// CancelledByUser = 'cancelledByUser', -// RejectedByRestaurant = 'rejectedByRestaurant', +export interface OrderCarAddress { + carModel: string; + carColor: string; + plateNumber: string; + address?: string; + latitude?: number; + longitude?: number; + fullName: string; + phone: string; +} -// // Active processing statuses -// Confirmed = 'confirmed', -// Preparing = 'preparing', - -// // Ready for pickup/delivery statuses -// ReadyForCustomerPickup = 'readyForCustomerPickup', -// ReadyForDineIn = 'readyForDineIn', -// ReadyForDeliveryCar = 'readyForDeliveryCar', -// ReadyForDeliveryCourier = 'readyForDeliveryCourier', - -// Shipping = 'shipping', -// // Final status -// Delivered = 'delivered', -// } export enum OrderStatus { NEW = 'new', PENDING_PAYMENT = 'pendingPayment', @@ -32,7 +31,7 @@ export enum OrderStatus { READY = 'ready', SHIPPED = 'shipped', COMPLETED = 'completed', - + CANCELED = 'canceled', FAILED = 'failed', REFUNDED = 'refunded', diff --git a/src/modules/orders/providers/orders.service.ts b/src/modules/orders/providers/orders.service.ts index 917ce6f..61516f2 100644 --- a/src/modules/orders/providers/orders.service.ts +++ b/src/modules/orders/providers/orders.service.ts @@ -5,7 +5,6 @@ import { OrderItem } from '../entities/order-item.entity'; import { User } from '../../users/entities/user.entity'; import { Restaurant } from '../../restaurants/entities/restaurant.entity'; import { Food } from '../../foods/entities/food.entity'; -import { UserAddress } from '../../users/entities/user-address.entity'; import { CartService } from '../../cart/providers/cart.service'; import { OrderStatus } from '../interface/order-status'; import { PaymentMethodEnum, PaymentStatusEnum } from '../../payments/interface/payment'; @@ -20,6 +19,7 @@ import { OrderRepository } from '../repositories/order.repository'; import { FindOrdersDto } from '../dto/find-orders.dto'; import { PaginatedResult } from 'src/common/interfaces/pagination.interface'; import { EventEmitter2 } from '@nestjs/event-emitter'; +import { OrderUserAddress, OrderCarAddress } from '../interface/order-status'; @Injectable() export class OrdersService { @@ -68,7 +68,8 @@ export class OrdersService { user: validationResult.user, restaurant: validationResult.restaurant, deliveryMethod: validationResult.delivery, - address: validationResult.address, + userAddress: validationResult.userAddress, + carAddress: validationResult.carAddress, paymentMethod: validationResult.paymentMethod, couponDiscount: cart.couponDiscount || 0, itemsDiscount: cart.itemsDiscount || 0, @@ -133,7 +134,8 @@ export class OrdersService { user: User; restaurant: Restaurant; delivery: Delivery; - address: UserAddress | null; + userAddress: OrderUserAddress | null; + carAddress: OrderCarAddress | null; paymentMethod: PaymentMethod; orderItemsData: Array<{ food: Food; quantity: number; unitPrice: number; discount: number }>; }> { @@ -185,19 +187,12 @@ export class OrdersService { } } - if (delivery.method === DeliveryMethodEnum.DeliveryCourier && !cart.addressId) { + if (delivery.method === DeliveryMethodEnum.DeliveryCourier && !cart.userAddress) { throw new BadRequestException('Address is required. Please set a delivery address before creating an order.'); } - let address: UserAddress | null = null; - if (cart.addressId) { - address = await this.em.findOne(UserAddress, { id: cart.addressId }, { populate: ['user'] }); - if (!address) { - throw new NotFoundException('Address not found'); - } - // Verify address belongs to the user - if (address.user.id !== userId) { - throw new BadRequestException('Address does not belong to the current user'); - } + + if (delivery.method === DeliveryMethodEnum.DeliveryCar && !cart.carAddress) { + throw new BadRequestException('Car address is required. Please set a car address before creating an order.'); } const paymentMethod = await this.em.findOne( @@ -256,8 +251,9 @@ export class OrdersService { user, restaurant, delivery, - address, paymentMethod, + userAddress: cart?.userAddress ?? null, + carAddress: cart?.carAddress ?? null, orderItemsData, }; } @@ -298,7 +294,18 @@ export class OrdersService { async findOne(id: string, restId: string): Promise { const order = await this.orderRepository.findOne( { id, restaurant: { id: restId } }, - { populate: ['user', 'restaurant', 'deliveryMethod', 'address', 'paymentMethod', 'items', 'items.food'] }, + { + populate: [ + 'user', + 'restaurant', + 'deliveryMethod', + 'userAddress', + 'carAddress', + 'paymentMethod', + 'items', + 'items.food', + ], + }, ); if (!order) { throw new NotFoundException('Order not found'); diff --git a/src/modules/payments/gateways/zarinpal.gateway.ts b/src/modules/payments/gateways/zarinpal.gateway.ts new file mode 100755 index 0000000..aeb8fa1 --- /dev/null +++ b/src/modules/payments/gateways/zarinpal.gateway.ts @@ -0,0 +1,167 @@ +import { HttpService } from "@nestjs/axios"; +import { Inject, Injectable, InternalServerErrorException, Logger } from "@nestjs/common"; +import { AxiosError } from "axios"; +import { catchError, firstValueFrom, throwError } from "rxjs"; + +import { PaymentMessage } from "../../../common/enums/message.enum"; +import { IZarinpalConfig } from "../../../configs/zarinpal.config"; +import { ZARINPAL_CONFIG } from "../constants"; +import { GatewayEnum } from "../enums/gateway.enum"; +import { + IPaymentGateway, + IPaymentVerifyParams, + IProcessPaymentParams, + ZarinPalPGNewArgs, + ZarinPalPGNewRequestData, + ZarinPalPGVerifyData, +} from "../interfaces/IPayment"; + +@Injectable() +export class ZarinpalGateway implements IPaymentGateway { + private readonly IPG_TYPE = "payment"; + private readonly logger = new Logger(ZarinpalGateway.name); + private readonly gatewayApiUrl: string; + private readonly requestHeader: Record = { "Content-Type": "application/json", Accept: "application/json" }; + + constructor( + @Inject(ZARINPAL_CONFIG) private readonly config: IZarinpalConfig, + private readonly httpService: HttpService, + ) { + this.gatewayApiUrl = `https://${this.IPG_TYPE}.zarinpal.com`; + } + + async processPayment(processParams: IProcessPaymentParams) { + try { + const purchaseData: ZarinPalPGNewArgs = { + merchant_id: this.config.merchantId, + amount: processParams.amount, + callback_url: `${this.config.callBackUrl}/${GatewayEnum.ZARINPAL}`, + description: processParams.description, + currency: "IRT", + metadata: { email: processParams.email, mobile: processParams.mobile }, + }; + + this.logger.log(`Processing payment request:`, { + merchant_id: this.config.merchantId, + amount: processParams.amount, + callback_url: `${this.config.callBackUrl}/${GatewayEnum.ZARINPAL}`, + description: processParams.description, + }); + + const { data } = await firstValueFrom( + this.httpService + .post(`${this.gatewayApiUrl}/pg/v4/payment/request.json`, purchaseData, { + headers: this.requestHeader, + }) + .pipe( + catchError((err: AxiosError) => { + this.logger.error(`Payment request failed: ${err.message}`, err.stack); + return throwError(() => new InternalServerErrorException(PaymentMessage.ERROR_IN_PROCESS_PAYMENT)); + }), + ), + ); + + // Check for errors in response + if (data.errors && data.errors.length > 0) { + this.logger.error(`Zarinpal payment request error:`, data.errors); + throw new InternalServerErrorException(PaymentMessage.ERROR_IN_PROCESS_PAYMENT); + } + + // Validate response data + if (!data.data?.authority) { + this.logger.error(`Invalid response from Zarinpal:`, data); + throw new InternalServerErrorException(PaymentMessage.ERROR_IN_PROCESS_PAYMENT); + } + + this.logger.log(`Payment request successful - Authority: ${data.data.authority}`); + + return { + redirectUrl: `${this.gatewayApiUrl}/pg/StartPay/${data.data.authority}`, + message: data.data.message, + reference: data.data.authority, + }; + } catch (error) { + this.logger.error(`Payment processing error:`, error); + throw new InternalServerErrorException(PaymentMessage.ERROR_IN_PROCESS_PAYMENT); + } + } + + async verifyPayment(verifyParams: IPaymentVerifyParams) { + const verifyData = { + merchant_id: this.config.merchantId, + amount: Number(verifyParams.amount), + authority: verifyParams.reference, + }; + + this.logger.log(`Verifying payment:`, { + merchant_id: this.config.merchantId, + amount: Number(verifyParams.amount), + authority: verifyParams.reference, + }); + + try { + const { data } = await firstValueFrom( + this.httpService + .post(`${this.gatewayApiUrl}/pg/v4/payment/verify.json`, verifyData, { + headers: this.requestHeader, + }) + .pipe( + catchError((err: AxiosError) => { + this.logger.error(`Verification request failed: ${err.message}`, err.stack); + + // If we have error response data from Zarinpal, extract it + if (err.response?.data) { + const errorData = err.response.data as ZarinPalPGVerifyData; + this.logger.error(`Zarinpal error response:`, errorData); + + // Return the Zarinpal error response if it has the expected structure + if (errorData.errors) { + return throwError(() => errorData); + } + } + + return throwError(() => new InternalServerErrorException(PaymentMessage.ERROR_IN_VERIFY_PAYMENT)); + }), + ), + ); + + // Log the verification result + if (data.data) { + this.logger.log(`Verification response - Code: ${data.data.code}, RefID: ${data.data.ref_id || "N/A"}`); + } else if (data.errors && data.errors.length > 0) { + this.logger.warn(`Verification error - Code: ${data.errors[0].code}, Message: ${data.errors[0].message}`); + } + + // Return the complete Zarinpal response - let the service handle the business logic + return { + code: data.data?.code || data.errors?.[0]?.code || -1, + message: data.data?.message || data.errors?.[0]?.message || "Unknown error", + ref_id: data.data?.ref_id || 0, + card_hash: data.data?.card_hash || "", + card_pan: data.data?.card_pan || "", + fee_type: data.data?.fee_type || "", + fee: data.data?.fee || 0, + }; + } catch (error) { + // If it's a Zarinpal error response, extract the error code and return it + if (error && typeof error === "object" && "errors" in error) { + const zarinpalError = error as ZarinPalPGVerifyData; + const firstError = zarinpalError.errors[0]; + this.logger.warn(`Zarinpal verification error - Code: ${firstError.code}, Message: ${firstError.message}`); + + return { + code: firstError.code, + message: firstError.message, + ref_id: 0, + card_hash: "", + card_pan: "", + fee_type: "", + fee: 0, + }; + } + + this.logger.error(`Payment verification error:`, error); + throw new InternalServerErrorException(PaymentMessage.ERROR_IN_VERIFY_PAYMENT); + } + } +}