structure
This commit is contained in:
@@ -0,0 +1,514 @@
|
||||
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 { PaymentMethodEnum, 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';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { OrderCreatedEvent, OrderPaymentSuccessEvent } from '../../notifications/events/notification.events';
|
||||
|
||||
@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 eventEmitter2: EventEmitter2,
|
||||
// private readonly paymentGatewayService: PaymentGatewayService,
|
||||
) {}
|
||||
|
||||
async checkout(userId: string, restaurantId: string) {
|
||||
console.log('Order created event emitted 000');
|
||||
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);
|
||||
|
||||
this.eventEmitter2.emit(
|
||||
OrderCreatedEvent.name,
|
||||
new OrderCreatedEvent(restaurantId, order.user.id, order.id, order.total),
|
||||
);
|
||||
this.eventEmitter2.emit(
|
||||
OrderPaymentSuccessEvent.name,
|
||||
new OrderPaymentSuccessEvent(restaurantId, order.user.id, order.id, order.total),
|
||||
);
|
||||
console.log('Order created event emitted 111');
|
||||
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,
|
||||
description: cart.description,
|
||||
tableNumber: cart.tableNumber,
|
||||
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}.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Validate table number is required for DineIn
|
||||
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.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 findAllForUser(restId: string, dto: FindOrdersDto, userId: string): Promise<PaginatedResult<Order>> {
|
||||
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<PaginatedResult<Order>> {
|
||||
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<Order> {
|
||||
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');
|
||||
}
|
||||
if (order.status !== OrderStatus.Pending) {
|
||||
throw new BadRequestException('Order must be pending before accepting');
|
||||
}
|
||||
// Wallet payments should be paid before accepting (like Online)
|
||||
if (order.paymentMethod?.method === PaymentMethodEnum.Online && order.paymentStatus !== PaymentStatusEnum.Paid) {
|
||||
throw new BadRequestException('Order must be paid online before accepting');
|
||||
}
|
||||
|
||||
// Cash, CardOnDelivery, and Wallet can be accepted even if payment status is pending
|
||||
// (they're paid on delivery or already deducted)
|
||||
|
||||
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');
|
||||
}
|
||||
if (order.status !== OrderStatus.Confirmed) {
|
||||
throw new BadRequestException('Order must be confirmed before preparing');
|
||||
}
|
||||
order.status = OrderStatus.Preparing;
|
||||
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 cancelOrderAsUser(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;
|
||||
}
|
||||
|
||||
async markAsDelivered(orderId: string, restId: string) {
|
||||
const order = await this.em.findOne(Order, { id: orderId, restaurant: { id: restId } });
|
||||
if (!order) {
|
||||
throw new NotFoundException('Order not found');
|
||||
}
|
||||
|
||||
const readyStatuses = [
|
||||
OrderStatus.ReadyForCustomerPickup,
|
||||
OrderStatus.ReadyForDineIn,
|
||||
OrderStatus.ReadyForDeliveryCar,
|
||||
OrderStatus.ReadyForDeliveryCourier,
|
||||
];
|
||||
|
||||
if (!readyStatuses.includes(order.status)) {
|
||||
throw new BadRequestException(
|
||||
`Order must be in a ready status to mark as delivered. Current status: ${order.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
order.status = OrderStatus.Delivered;
|
||||
await this.em.persistAndFlush(order);
|
||||
return order;
|
||||
}
|
||||
|
||||
async updateStatus(orderId: string, status: string, restId: string) {
|
||||
const order = await this.em.findOne(Order, { id: orderId, restaurant: { id: restId } });
|
||||
if (!order) {
|
||||
throw new NotFoundException('Order not found');
|
||||
}
|
||||
|
||||
// Map string status to OrderStatus enum
|
||||
const statusMap: Record<string, OrderStatus> = {
|
||||
ReadyForCustomerPickup: OrderStatus.ReadyForCustomerPickup,
|
||||
readyForCustomerPickup: OrderStatus.ReadyForCustomerPickup,
|
||||
ReadyForDineIn: OrderStatus.ReadyForDineIn,
|
||||
readyForDineIn: OrderStatus.ReadyForDineIn,
|
||||
ReadyForDeliveryCar: OrderStatus.ReadyForDeliveryCar,
|
||||
readyForDeliveryCar: OrderStatus.ReadyForDeliveryCar,
|
||||
ReadyForDeliveryCourier: OrderStatus.ReadyForDeliveryCourier,
|
||||
readyForDeliveryCourier: OrderStatus.ReadyForDeliveryCourier,
|
||||
shipping: OrderStatus.Shipping,
|
||||
Delivered: OrderStatus.Delivered,
|
||||
delivered: OrderStatus.Delivered,
|
||||
};
|
||||
|
||||
const newStatus = statusMap[status];
|
||||
if (!newStatus) {
|
||||
throw new BadRequestException(
|
||||
`Invalid status: ${status}. Allowed statuses: shipping, Delivered, ReadyForCustomerPickup, ReadyForDineIn, ReadyForDeliveryCar, ReadyForDeliveryCourier`,
|
||||
);
|
||||
}
|
||||
|
||||
order.status = newStatus;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user