Files
dmenu-api/src/modules/orders/orders.service.ts
T
2025-12-07 12:13:09 +03:30

412 lines
14 KiB
TypeScript

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,
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 findAll(restId: string, dto: FindOrdersDto): Promise<PaginatedResult<Order>> {
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<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');
}
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;
}
}
}