425 lines
15 KiB
TypeScript
425 lines
15 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 { 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';
|
|
|
|
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, readonly OrderStatus[]> = {
|
|
[OrderStatus.NEW]: [OrderStatus.PENDING_PAYMENT, OrderStatus.CONFIRMED, OrderStatus.CANCELED],
|
|
[OrderStatus.PENDING_PAYMENT]: [OrderStatus.PAID, OrderStatus.FAILED, OrderStatus.CANCELED],
|
|
[OrderStatus.PAID]: [OrderStatus.CONFIRMED, OrderStatus.REFUNDED],
|
|
[OrderStatus.CONFIRMED]: [OrderStatus.PREPARING, OrderStatus.CANCELED],
|
|
[OrderStatus.PREPARING]: [OrderStatus.READY, OrderStatus.SHIPPED, OrderStatus.CANCELED],
|
|
[OrderStatus.READY]: [OrderStatus.COMPLETED, OrderStatus.FAILED],
|
|
[OrderStatus.SHIPPED]: [OrderStatus.COMPLETED, OrderStatus.FAILED],
|
|
[OrderStatus.COMPLETED]: [],
|
|
[OrderStatus.CANCELED]: [],
|
|
[OrderStatus.FAILED]: [],
|
|
[OrderStatus.REFUNDED]: [],
|
|
};
|
|
|
|
constructor(
|
|
private readonly em: EntityManager,
|
|
private readonly cartService: CartService,
|
|
private readonly orderRepository: OrderRepository,
|
|
private readonly paymentsService: PaymentsService,
|
|
private readonly inventoryService: InventoryService,
|
|
) { }
|
|
|
|
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.NEW,
|
|
paymentStatus: PaymentStatusEnum.Pending,
|
|
});
|
|
|
|
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})`);
|
|
return order;
|
|
});
|
|
|
|
await this.cartService.clearCart(userId, restaurantId);
|
|
|
|
const { paymentUrl } = await this.paymentsService.payOrder(order.id);
|
|
|
|
return { paymentUrl };
|
|
}
|
|
|
|
/**
|
|
* Validates cart and prepares all required data for order creation
|
|
*/
|
|
private async validateCartForOrder(userId: string, restaurantId: string, cart: Cart): Promise<ValidatedCartForOrder> {
|
|
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<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',
|
|
'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, status: OrderStatus, restId: string) {
|
|
return this.changeOrderStatus(orderId, restId, status);
|
|
}
|
|
|
|
private async changeOrderStatus(
|
|
orderId: string,
|
|
restId: string,
|
|
toStatus: OrderStatus,
|
|
options?: { skipTransitionValidation?: boolean },
|
|
): Promise<Order> {
|
|
const order = await this.getOrderOrFail(orderId, restId);
|
|
|
|
if (!options?.skipTransitionValidation) {
|
|
this.assertStatusTransitionAllowed(order, toStatus);
|
|
}
|
|
|
|
order.status = toStatus;
|
|
await this.em.persistAndFlush(order);
|
|
return order;
|
|
}
|
|
|
|
private assertStatusTransitionAllowed(order: Order, to: OrderStatus) {
|
|
const paymentMethod = order.paymentMethod?.method;
|
|
if (!paymentMethod) {
|
|
throw new BadRequestException('Order payment method is missing');
|
|
}
|
|
|
|
if (!this.canTransition(order.status, to, paymentMethod)) {
|
|
throw new BadRequestException(`Invalid status transition: ${order.status} -> ${to}`);
|
|
}
|
|
}
|
|
|
|
private canTransition(from: OrderStatus, to: OrderStatus, paymentMethod: PaymentMethodEnum) {
|
|
if (!OrdersService.STATUS_TRANSITIONS[from]?.includes(to)) 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<Order> {
|
|
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<User> {
|
|
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<Restaurant> {
|
|
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<Delivery> {
|
|
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<PaymentMethod> {
|
|
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<OrderItemData[]> {
|
|
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}`,
|
|
);
|
|
}
|
|
}
|
|
}
|