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 { CartService } from '../../cart/providers/cart.service'; import { OrderStatus, OrderUserAddress, OrderCarAddress } from '../interface/order.interface'; import { PaymentMethodEnum, PaymentStatusEnum } from '../../payments/interface/payment'; import { Cart } from '../../cart/interfaces/cart.interface'; import { PaymentMethod } from '../../payments/entities/payment-method.entity'; import { PaymentsService } from '../../payments/services/payments.service'; import { DeliveryMethodEnum } from '../../delivery/interface/delivery'; import { Delivery } from '../../delivery/entities/delivery.entity'; import { OrderRepository } from '../repositories/order.repository'; import { FindOrdersDto } from '../dto/find-orders.dto'; import { PaginatedResult } from 'src/common/interfaces/pagination.interface'; import { Payment } from 'src/modules/payments/entities/payment.entity'; import { InventoryService } from 'src/modules/inventory/inventory.service'; import { BulkReserveFoodDto } from 'src/modules/inventory/dto/bulk-reserve-food.dto'; import { StatusTransitionRef } from '../interface/order.interface'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { OrderCreatedEvent, OrderStatusChangedEvent } from '../events/order.events'; type OrderItemData = { food: Food; quantity: number; unitPrice: number; discount: number }; type ValidatedCartForOrder = { user: User; restaurant: Restaurant; delivery: Delivery; userAddress: OrderUserAddress | null; carAddress: OrderCarAddress | null; paymentMethod: PaymentMethod; orderItemsData: OrderItemData[]; }; @Injectable() export class OrdersService { private readonly logger = new Logger(OrdersService.name); private static readonly STATUS_TRANSITIONS: Record = { [OrderStatus.PENDING_PAYMENT]: [OrderStatus.PAID, OrderStatus.CANCELED, OrderStatus.CONFIRMED], [OrderStatus.PAID]: [OrderStatus.CONFIRMED, OrderStatus.CANCELED], [OrderStatus.CONFIRMED]: [OrderStatus.PREPARING, OrderStatus.CANCELED], [OrderStatus.PREPARING]: [OrderStatus.READY, OrderStatus.SHIPPED, OrderStatus.CANCELED], [OrderStatus.READY]: [OrderStatus.COMPLETED, OrderStatus.CANCELED], [OrderStatus.SHIPPED]: [OrderStatus.COMPLETED, OrderStatus.CANCELED], [OrderStatus.COMPLETED]: [OrderStatus.CANCELED], [OrderStatus.CANCELED]: [], }; constructor( private readonly em: EntityManager, private readonly cartService: CartService, private readonly orderRepository: OrderRepository, private readonly paymentsService: PaymentsService, private readonly inventoryService: InventoryService, private readonly eventEmitter: EventEmitter2, ) {} async checkout(userId: string, restaurantId: string) { const cart = await this.cartService.findOneOrFail(userId, restaurantId); const validated = await this.validateCartForOrder(userId, restaurantId, cart); const order = await this.em.transactional(async em => { const order = em.create(Order, { user: validated.user, restaurant: validated.restaurant, deliveryMethod: validated.delivery, userAddress: validated.userAddress, carAddress: validated.carAddress, paymentMethod: validated.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, description: cart.description, tableNumber: cart.tableNumber, status: OrderStatus.PENDING_PAYMENT, history: [{ status: OrderStatus.PENDING_PAYMENT, changedAt: new Date() }], }); em.persist(order); for (const itemData of validated.orderItemsData) { const { food, quantity, unitPrice, discount } = itemData; this.assertFoodHasSufficientStock(food, quantity); const totalPrice = (unitPrice - discount) * quantity; const orderItem = em.create(OrderItem, { order, food, quantity, unitPrice, discount, totalPrice, }); em.persist(orderItem); } const payment = em.create(Payment, { order, amount: order.total, status: PaymentStatusEnum.Pending, method: order.paymentMethod.method, gateway: order.paymentMethod.gateway ?? null, }); em.persist(payment); // reserve stock based on payment method. const bulkReserveFoodDto: BulkReserveFoodDto = { items: validated.orderItemsData.map(item => ({ foodId: item.food.id, quantity: item.quantity, })), }; await this.inventoryService.deductFromInventory(em, bulkReserveFoodDto); await em.flush(); this.logger.debug(`Order ${order.id} created for user ${userId} (restaurant ${restaurantId})`); this.eventEmitter.emit( OrderCreatedEvent.name, new OrderCreatedEvent(order.id, restaurantId, String(order?.orderNumber) || ''), ); return order; }); await this.cartService.clearCart(userId, restaurantId); const { paymentUrl } = await this.paymentsService.payOrder(order.id); return { paymentUrl, order }; } /** * Validates cart and prepares all required data for order creation */ private async validateCartForOrder(userId: string, restaurantId: string, cart: Cart): Promise { this.assertCartHasItems(cart); this.assertCartHasDeliveryMethod(cart); this.assertCartHasPaymentMethod(cart); const [user, restaurant, delivery] = await Promise.all([ this.getUserOrFail(userId), this.getRestaurantOrFail(restaurantId), this.getDeliveryOrFail(cart.deliveryMethodId!), ]); this.assertMeetsMinOrderForDelivery(cart, delivery); this.assertDeliveryMethodRequirements(cart, delivery); const paymentMethod = await this.getPaymentMethodOrFail(cart.paymentMethodId!, restaurantId); this.assertPaymentMethodEnabled(paymentMethod); const orderItemsData = await this.buildOrderItemsData(cart, restaurantId); return { user, restaurant, delivery, paymentMethod, userAddress: delivery.method === DeliveryMethodEnum.DeliveryCourier ? (cart?.userAddress ?? null) : null, carAddress: delivery.method === DeliveryMethodEnum.DeliveryCar ? (cart?.carAddress ?? null) : null, orderItemsData, }; } async findAllForUser(restId: string, dto: FindOrdersDto, userId: string): Promise> { const result = await 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, userId, }); return result; } async findAllForAdmin(restId: string, dto: FindOrdersDto): Promise> { const result = await 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, }); return result; } async findOne(id: string, restId: string): Promise { const order = await this.orderRepository.findOne( { id, restaurant: { id: restId } }, { populate: [ 'user', 'restaurant', 'deliveryMethod', 'userAddress', 'carAddress', 'paymentMethod', 'items', 'items.food', ], }, ); if (!order) { throw new NotFoundException('Order not found'); } return order; } // async confirmOrder(orderId: string, restId: string) { // return this.changeOrderStatus(orderId, restId, OrderStatus.CONFIRMED); // } // async prepareOrder(orderId: string, restId: string) { // return this.changeOrderStatus(orderId, restId, OrderStatus.PREPARING); // } // just admin can reject the order any time // async rejectOrder(orderId: string, restId: string) { // return this.changeOrderStatus(orderId, restId, OrderStatus.CANCELED, { skipTransitionValidation: true }); // } // async readyForDelivery(orderId: string, restId: string) { // return this.changeOrderStatus(orderId, restId, OrderStatus.READY); // } // async cancelOrderAsUser(orderId: string, restId: string) { // return this.changeOrderStatus(orderId, restId, OrderStatus.CANCELED); // } // async markAsDelivered(orderId: string, restId: string) { // return this.changeOrderStatus(orderId, restId, OrderStatus.COMPLETED); // } // async updateStatus(orderId: string, restId: string, status: OrderStatus, ref: StatusTransitionRef) { // return this.changeOrderStatus(orderId, restId, status, ref); // } async changeOrderStatus( orderId: string, restId: string, toStatus: OrderStatus, ref: StatusTransitionRef, desc?: string, ): Promise { const order = await this.getOrderOrFail(orderId, restId); this.assertStatusTransitionAllowed(order, toStatus, ref); order.status = toStatus; order.history.push({ status: toStatus, changedAt: new Date(), desc: desc || null }); await this.em.persistAndFlush(order); this.eventEmitter.emit( OrderStatusChangedEvent.name, new OrderStatusChangedEvent( orderId, order.user?.id || '', String(order?.orderNumber) || '', restId, order.status, toStatus, ref, ), ); return order; } /* Helpers */ private assertStatusTransitionAllowed(order: Order, to: OrderStatus, ref: StatusTransitionRef) { const paymentMethod = order.paymentMethod?.method; if (!paymentMethod) { throw new BadRequestException('Order payment method is missing'); } if (!this.canTransition(order.status, to, paymentMethod, ref)) { throw new BadRequestException(`Invalid status transition: ${order.status} -> ${to}`); } } private canTransition(from: OrderStatus, to: OrderStatus, paymentMethod: PaymentMethodEnum, ref: 'user' | 'admin') { if (!OrdersService.STATUS_TRANSITIONS[from]?.includes(to)) return false; if (to === OrderStatus.CANCELED) { // only allow orders with status of PENDING_PAYMENT and PAID are allowed to be canceled by user if (ref === 'user' && ![OrderStatus.PENDING_PAYMENT, OrderStatus.PAID, OrderStatus.CONFIRMED].includes(from)) { return false; } else if (ref === 'admin') { return true; } } // only allow orders with status of PENDING_PAYMENT and payment // method of cash are allowed to move to CONFIRMED directly if ( from == OrderStatus.PENDING_PAYMENT && to == OrderStatus.CONFIRMED && paymentMethod !== PaymentMethodEnum.Cash ) { return false; } // if (paymentMethod === PaymentMethodEnum.Cash) { // if ([OrderStatus.PENDING_PAYMENT, OrderStatus.PAID].includes(to)) return false; // } if (paymentMethod === PaymentMethodEnum.Online) { if (to === OrderStatus.CONFIRMED && from !== OrderStatus.PAID) return false; } return true; } private async getOrderOrFail(orderId: string, restId: string): Promise { const order = await this.em.findOne( Order, { id: orderId, restaurant: { id: restId } }, { populate: ['paymentMethod'] }, ); if (!order) throw new NotFoundException('Order not found'); return order; } private assertCartHasItems(cart: Cart) { if (!cart.items || cart.items.length === 0) { throw new BadRequestException('Cart is empty. Add items to cart before creating an order.'); } } private assertCartHasDeliveryMethod(cart: Cart) { if (!cart.deliveryMethodId) { throw new NotFoundException('Delivery method not found'); } } private assertCartHasPaymentMethod(cart: Cart) { if (!cart.paymentMethodId) { throw new BadRequestException( 'Payment method is required. Please set a payment method before creating an order.', ); } } private async getUserOrFail(userId: string): Promise { const user = await this.em.findOne(User, { id: userId }); if (!user) throw new NotFoundException('User not found'); return user; } private async getRestaurantOrFail(restaurantId: string): Promise { const restaurant = await this.em.findOne(Restaurant, { id: restaurantId }); if (!restaurant) throw new NotFoundException('Restaurant not found'); return restaurant; } private async getDeliveryOrFail(deliveryId: string): Promise { const delivery = await this.em.findOne(Delivery, { id: deliveryId }); if (!delivery) throw new NotFoundException('Delivery not found'); return delivery; } private assertMeetsMinOrderForDelivery(cart: Cart, delivery: Delivery) { 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}.`, ); } } private assertDeliveryMethodRequirements(cart: Cart, delivery: Delivery) { if (delivery.method === DeliveryMethodEnum.DineIn) { if (!cart.tableNumber || cart.tableNumber.trim() === '') { throw new BadRequestException('Table number is required when delivery method is DineIn'); } } if (delivery.method === DeliveryMethodEnum.DeliveryCourier && !cart.userAddress) { throw new BadRequestException('Address is required. Please set a delivery address before creating an order.'); } if (delivery.method === DeliveryMethodEnum.DeliveryCar && !cart.carAddress) { throw new BadRequestException('Car address is required. Please set a car address before creating an order.'); } } private async getPaymentMethodOrFail(paymentMethodId: string, restaurantId: 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}`); } return paymentMethod; } private assertPaymentMethodEnabled(paymentMethod: PaymentMethod) { if (!paymentMethod.enabled) { throw new BadRequestException('Payment method is not enabled for this restaurant'); } } private async buildOrderItemsData(cart: Cart, restaurantId: string): Promise { const orderItemsData: OrderItemData[] = []; for (const cartItem of cart.items) { const food = await this.em.findOne(Food, { id: cartItem.foodId }, { populate: ['restaurant', 'inventory'] }); if (!food) throw new NotFoundException(`Food with ID ${cartItem.foodId} not found`); if (food.restaurant.id !== restaurantId) { throw new BadRequestException(`Food ${food.title || food.id} does not belong to restaurant ${restaurantId}`); } this.assertFoodHasSufficientStock(food, cartItem.quantity); orderItemsData.push({ food, quantity: cartItem.quantity, unitPrice: food.price || 0, discount: food.discount || 0, }); } return orderItemsData; } private assertFoodHasSufficientStock(food: Food, quantity: number) { const availableStock = food.inventory?.availableStock ?? 0; if (availableStock < quantity) { throw new BadRequestException( `Insufficient stock for food ${food.title || food.id}. Available: ${availableStock}, Requested: ${quantity}`, ); } } }