import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common'; import { EntityManager } from '@mikro-orm/postgresql'; import { Order } from './entities/order.entity'; 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 { PaymentStatusEnum } from '../payments/interface/payment'; import { Cart } from '../cart/interfaces/cart.interface'; import { PaymentMethod } from '../payments/entities/payment-method.entity'; // import { PaymentGatewayService } from '../payments/services/payment-gateway.service.tss'; import { PaymentsService } from '../payments/services/payments.service'; import { DeliveryMethodEnum } from '../delivery/interface/delivery'; import { Delivery } from '../delivery/entities/delivery.entity'; import { Cron, CronExpression } from '@nestjs/schedule'; import { OrderRepository } from './repositories/order.repository'; import { FindOrdersDto } from './dto/find-orders.dto'; import { PaginatedResult } from 'src/common/interfaces/pagination.interface'; @Injectable() export class OrdersService { private readonly logger = new Logger(OrdersService.name); constructor( private readonly em: EntityManager, private readonly cartService: CartService, private readonly paymentsService: PaymentsService, private readonly orderRepository: OrderRepository, // private readonly paymentGatewayService: PaymentGatewayService, ) {} async checkout(userId: string, restaurantId: string) { const cart = await this.cartService.findOneOrFail(userId, restaurantId); const { order } = await this.createOrder(userId, restaurantId, cart); if (!order.paymentMethod) { throw new BadRequestException('Payment method is required for checkout'); } const { paymentUrl } = await this.paymentsService.startPayment(order.id); await this.cartService.clearCart(userId, restaurantId); return { order, paymentUrl }; } async createOrder(userId: string, restaurantId: string, cart: Cart) { const validationResult = await this.validateCartForOrder(userId, restaurantId, cart); // Create order within a transaction return this.em.transactional(async em => { // Create order entity const order = em.create(Order, { user: validationResult.user, restaurant: validationResult.restaurant, deliveryMethod: validationResult.delivery, address: validationResult.address, paymentMethod: validationResult.paymentMethod, couponDiscount: cart.couponDiscount || 0, itemsDiscount: cart.itemsDiscount || 0, totalDiscount: cart.totalDiscount || 0, subTotal: cart.subTotal || 0, tax: cart.tax || 0, deliveryFee: cart.deliveryFee || 0, total: cart.total || 0, totalItems: cart.totalItems || 0, status: OrderStatus.Pending, paymentStatus: PaymentStatusEnum.Pending, }); em.persist(order); // Create order items and update stock for (const itemData of validationResult.orderItemsData) { const { food, quantity, unitPrice, discount } = itemData; // Stock check if (food.stock < quantity) { throw new BadRequestException(`${food.title} is out of stock`); } const totalPrice = (unitPrice - discount) * quantity; const orderItem = em.create(OrderItem, { order, food, quantity, unitPrice, discount, totalPrice, }); em.persist(orderItem); // Update food stock food.stock -= quantity; em.persist(food); } // Flush all changes await em.flush(); return { order }; }); } /** * Validates cart and prepares all required data for order creation */ private async validateCartForOrder( userId: string, restaurantId: string, cart: Cart, ): Promise<{ user: User; restaurant: Restaurant; delivery: Delivery; address: UserAddress | null; paymentMethod: PaymentMethod; orderItemsData: Array<{ food: Food; quantity: number; unitPrice: number; discount: number }>; }> { // Validate cart has items if (!cart.items || cart.items.length === 0) { throw new BadRequestException('Cart is empty. Add items to cart before creating an order.'); } // Validate address is set if (!cart.deliveryMethodId) { throw new NotFoundException('Delivery method not found'); } // Validate payment method is set if (!cart.paymentMethodId) { throw new BadRequestException( 'Payment method is required. Please set a payment method before creating an order.', ); } // Validate and load entities const user = await this.em.findOne(User, { id: userId }); if (!user) { throw new NotFoundException('User not found'); } const restaurant = await this.em.findOne(Restaurant, { id: restaurantId }); if (!restaurant) { throw new NotFoundException('Restaurant not found'); } const delivery = await this.em.findOne(Delivery, { id: cart.deliveryMethodId }); if (!delivery) { throw new NotFoundException('Delivery not found'); } // Validate minimum order price for delivery method const minOrderPrice = Number(delivery.minOrderPrice) || 0; if (minOrderPrice > 0 && cart.total < minOrderPrice) { throw new BadRequestException( `Minimum order amount for this delivery method is ${minOrderPrice}. Current total is ${cart.total}.`, ); } if (delivery.method === DeliveryMethodEnum.DeliveryCourier && !cart.addressId) { 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'); } } const paymentMethod = await this.em.findOne( PaymentMethod, { id: cart.paymentMethodId, restaurant: { id: restaurantId }, }, { populate: ['restaurant'] }, ); if (!paymentMethod) { throw new NotFoundException( `Payment method with ID ${cart.paymentMethodId} not found for restaurant ${restaurantId}`, ); } if (!paymentMethod.enabled) { throw new BadRequestException('Payment method is not enabled for this restaurant'); } // Validate stock and prepare order items data const orderItemsData: Array<{ food: Food; quantity: number; unitPrice: number; discount: number }> = []; for (const cartItem of cart.items) { const food = await this.em.findOne(Food, { id: cartItem.foodId }, { populate: ['restaurant'] }); if (!food) { throw new NotFoundException(`Food with ID ${cartItem.foodId} not found`); } // Verify food belongs to the restaurant if (food.restaurant.id !== restaurantId) { throw new BadRequestException(`Food ${food.title || food.id} does not belong to restaurant ${restaurantId}`); } // Final stock validation if (food.stock < cartItem.quantity) { throw new BadRequestException( `Insufficient stock for food ${food.title || food.id}. Available: ${food.stock}, Requested: ${cartItem.quantity}`, ); } const unitPrice = food.price || 0; const discount = food.discount || 0; orderItemsData.push({ food, quantity: cartItem.quantity, unitPrice, discount, }); } return { user, restaurant, delivery, address, paymentMethod, orderItemsData, }; } async findAll(restId: string, dto: FindOrdersDto): Promise> { return this.orderRepository.findAllPaginated(restId, { page: dto.page, limit: dto.limit, status: dto.status, paymentStatus: dto.paymentStatus, search: dto.search, startDate: dto.startDate, endDate: dto.endDate, orderBy: dto.orderBy, order: dto.order, }); } 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'] }, ); if (!order) { throw new NotFoundException('Order not found'); } return order; } async acceptOrder(orderId: string, restId: string) { const order = await this.em.findOne(Order, { id: orderId, restaurant: { id: restId } }); if (!order) { throw new NotFoundException('Order not found'); } order.status = OrderStatus.Confirmed; await this.em.persistAndFlush(order); return order; } async prepareOrder(orderId: string, restId: string) { const order = await this.em.findOne(Order, { id: orderId, restaurant: { id: restId } }); if (!order) { throw new NotFoundException('Order not found'); } order.status = OrderStatus.Confirmed; await this.em.persistAndFlush(order); return order; } async rejectOrder(orderId: string, restId: string) { const order = await this.em.findOne(Order, { id: orderId, restaurant: { id: restId } }); if (!order) { throw new NotFoundException('Order not found'); } order.status = OrderStatus.RejectedByRestaurant; await this.em.persistAndFlush(order); return order; } async readyForDelivery(orderId: string, restId: string) { const order = await this.em.findOne(Order, { id: orderId, restaurant: { id: restId } }); if (!order) { throw new NotFoundException('Order not found'); } let orderStatus: OrderStatus | undefined; switch (order.deliveryMethod.method) { case DeliveryMethodEnum.DeliveryCourier: orderStatus = OrderStatus.ReadyForDeliveryCourier; break; case DeliveryMethodEnum.DeliveryCar: orderStatus = OrderStatus.ReadyForDeliveryCar; break; case DeliveryMethodEnum.DineIn: orderStatus = OrderStatus.ReadyForDineIn; break; case DeliveryMethodEnum.CustomerPickup: orderStatus = OrderStatus.ReadyForCustomerPickup; break; default: throw new BadRequestException('Invalid delivery method'); } order.status = orderStatus; await this.em.persistAndFlush(order); return order; } async cancelOrder(orderId: string, restId: string) { const order = await this.em.findOne(Order, { id: orderId, restaurant: { id: restId } }); if (!order) { throw new NotFoundException('Order not found'); } if (order.status !== OrderStatus.Pending) { throw new BadRequestException('Order is not pending'); } order.status = OrderStatus.CancelledByUser; await this.em.persistAndFlush(order); return order; } /** * Cleanup job to handle abandoned orders (pending payment for >30 minutes) * Runs every 10 minutes to check for abandoned orders */ @Cron(CronExpression.EVERY_10_MINUTES) async cleanupAbandonedOrders() { this.logger.log('Starting cleanup of abandoned orders...'); try { const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000); // Find abandoned orders: Pending status, Pending payment, created >30 minutes ago const abandonedOrders = await this.em.find( Order, { status: OrderStatus.Pending, paymentStatus: PaymentStatusEnum.Pending, createdAt: { $lt: thirtyMinutesAgo }, }, { populate: ['items', 'items.food'] }, ); if (abandonedOrders.length === 0) { this.logger.log('No abandoned orders found'); return; } this.logger.log(`Found ${abandonedOrders.length} abandoned order(s) to cleanup`); // Process each abandoned order in a transaction for (const order of abandonedOrders) { await this.em.transactional(async em => { // Load order items with food relations await order.items.loadItems(); // Restore stock for each item for (const item of order.items) { const food = item.food; if (food) { food.stock += item.quantity; em.persist(food); this.logger.debug( `Restored ${item.quantity} units of stock for food ${food.id} (${food.title || 'N/A'})`, ); } } // Update order status to Cancelled order.status = OrderStatus.CancelledBySystem; em.persist(order); await em.flush(); this.logger.log(`Cancelled abandoned order ${order.id}`); }); } this.logger.log(`Successfully cleaned up ${abandonedOrders.length} abandoned order(s)`); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const errorStack = error instanceof Error ? error.stack : undefined; this.logger.error(`Error during cleanup of abandoned orders: ${errorMessage}`, errorStack); throw error; } } }