diff --git a/src/modules/cart/interfaces/cart.interface.ts b/src/modules/cart/interfaces/cart.interface.ts index c786356..b281661 100644 --- a/src/modules/cart/interfaces/cart.interface.ts +++ b/src/modules/cart/interfaces/cart.interface.ts @@ -1,4 +1,4 @@ -import type { OrderCouponDetail, OrderUserAddress, OrderCarAddress } from 'src/modules/orders/interface/order-status'; +import type { OrderCouponDetail, OrderUserAddress, OrderCarAddress } from 'src/modules/orders/interface/order.interface'; export interface CartItem { foodId: string; diff --git a/src/modules/cart/providers/cart.service.ts b/src/modules/cart/providers/cart.service.ts index 8d6abb0..40f539f 100644 --- a/src/modules/cart/providers/cart.service.ts +++ b/src/modules/cart/providers/cart.service.ts @@ -18,7 +18,7 @@ import { Delivery } from '../../delivery/entities/delivery.entity'; import { DeliveryMethodEnum } from '../../delivery/interface/delivery'; import { Cart, CartItem } from '../interfaces/cart.interface'; import { CouponService } from 'src/modules/coupons/providers/coupon.service'; -import { OrderCouponDetail } from 'src/modules/orders/interface/order-status'; +import { OrderCouponDetail } from 'src/modules/orders/interface/order.interface'; import { CouponType } from 'src/modules/coupons/interface/coupon'; import { UserWallet } from 'src/modules/users/entities/user-wallet.entity'; import { PaymentMethodEnum } from 'src/modules/payments/interface/payment'; @@ -223,8 +223,8 @@ export class CartService { const cart = await this.findOneOrFail(userId, restaurantId); const paymentMethod = await this.getEnabledPaymentMethodOrFail(restaurantId, setPaymentMethodDto.paymentMethodId); - if (paymentMethod.method === PaymentMethodEnum.Wallet) { - await this.assertWalletHasEnoughBalance(userId, restaurantId, cart.total); + if (paymentMethod.method === PaymentMethodEnum.Wallet) { + await this.assertWalletHasEnoughBalance(userId, restaurantId, cart.total); } cart.paymentMethodId = setPaymentMethodDto.paymentMethodId; return this.recalculateAndSaveCart(cart); diff --git a/src/modules/inventory/const/reservation.ts b/src/modules/inventory/const/reservation.ts new file mode 100644 index 0000000..18f394a --- /dev/null +++ b/src/modules/inventory/const/reservation.ts @@ -0,0 +1,7 @@ +import { PaymentMethodEnum } from "src/modules/payments/interface/payment"; + +export const STOCK_RESERVE_MINUTES: Record = { + [PaymentMethodEnum.Online]: 15, + [PaymentMethodEnum.Cash]: 120, + [PaymentMethodEnum.Wallet]: 15, + }; \ No newline at end of file diff --git a/src/modules/inventory/dto/bulk-reserve-food.dto.ts b/src/modules/inventory/dto/bulk-reserve-food.dto.ts index edbdd32..a67965f 100644 --- a/src/modules/inventory/dto/bulk-reserve-food.dto.ts +++ b/src/modules/inventory/dto/bulk-reserve-food.dto.ts @@ -15,11 +15,6 @@ export class BulkReserveFoodItemDto { @Type(() => Number) quantity!: number; - @ApiProperty({ example: '2024-12-31T23:59:59Z', description: 'Reservation expiration date' }) - @IsNotEmpty() - @IsDate() - @Type(() => Date) - expiresAt!: Date; } export class BulkReserveFoodDto { diff --git a/src/modules/inventory/inteface/reservation.ts b/src/modules/inventory/inteface/reservation.ts index 411575c..c3f8f5a 100644 --- a/src/modules/inventory/inteface/reservation.ts +++ b/src/modules/inventory/inteface/reservation.ts @@ -2,3 +2,5 @@ export enum ReservationStatus { ACTIVE = 'active', CONFIRMED = 'confirmed', } + + diff --git a/src/modules/inventory/inventory.module.ts b/src/modules/inventory/inventory.module.ts index 8cf8f75..d333b5a 100644 --- a/src/modules/inventory/inventory.module.ts +++ b/src/modules/inventory/inventory.module.ts @@ -10,5 +10,6 @@ import { JwtModule } from '@nestjs/jwt'; imports: [MikroOrmModule.forFeature([Inventory]), AuthModule, JwtModule], controllers: [InventoryController], providers: [InventoryService], + exports: [InventoryService], }) export class InventoryModule {} diff --git a/src/modules/inventory/inventory.service.ts b/src/modules/inventory/inventory.service.ts index d01135b..aa9ad9a 100644 --- a/src/modules/inventory/inventory.service.ts +++ b/src/modules/inventory/inventory.service.ts @@ -9,6 +9,8 @@ import { Food } from '../foods/entities/food.entity'; import { Restaurant } from '../restaurants/entities/restaurant.entity'; import { Order } from '../orders/entities/order.entity'; import { ReservationStatus } from './inteface/reservation'; +import { STOCK_RESERVE_MINUTES } from './const/reservation'; +import { PaymentMethodEnum } from '../payments/interface/payment'; @Injectable() export class InventoryService { @@ -59,8 +61,8 @@ export class InventoryService { } async bulkSetStockForFoods(restaurantId: string, bulkSetStockDto: BulkSetStockDto): Promise { + const { items } = bulkSetStockDto; - // Validate all items first for (const item of items) { if (item.availableStock > item.totalStock) { @@ -134,17 +136,19 @@ export class InventoryService { } async tempBulkReserveFood( + em: EntityManager, restaurantId: string, orderId: string, + paymentMethod: PaymentMethodEnum, bulkReserveFoodDto: BulkReserveFoodDto, ): Promise { const { items } = bulkReserveFoodDto; - + const expiresAt = this.getReservationExpiry(paymentMethod); // Get all unique food IDs const foodIds = [...new Set(items.map(item => item.foodId))]; // Load all foods in one query - const foods = await this.em.find(Food, { id: { $in: foodIds } }, { populate: ['restaurant'] }); + const foods = await em.find(Food, { id: { $in: foodIds } }, { populate: ['restaurant'] }); // Verify all foods exist and belong to the restaurant const foodMap = new Map(); @@ -162,13 +166,13 @@ export class InventoryService { } // Load order - const order = await this.em.findOne(Order, { id: orderId }); + const order = await em.findOne(Order, { id: orderId }); if (!order) { throw new NotFoundException(`Order with ID ${orderId} not found`); } // Load all existing inventories in one query - const existingInventories = await this.em.find(Inventory, { + const existingInventories = await em.find(Inventory, { food: { id: { $in: foodIds }, restaurant: { id: restaurantId } }, }); @@ -196,11 +200,11 @@ export class InventoryService { } // Create reservation record - const reservation = this.em.create(Reservation, { + const reservation = em.create(Reservation, { food, order, quantity: item.quantity, - expiresAt: item.expiresAt, + expiresAt, status: ReservationStatus.ACTIVE, }); @@ -209,22 +213,36 @@ export class InventoryService { // Update available stock (decrease by quantity) inventory.availableStock -= item.quantity; } - - // Flush all changes at once - await this.em.flush(); + return reservations; } - async confirmReservationByOrderId(orderId: string): Promise { - const reservations = await this.em.find(Reservation, { order: { id: orderId } }); + async confirmReservationByOrderId(em: EntityManager, orderId: string): Promise { + const reservations = await em.find(Reservation, { order: { id: orderId } }); if (!reservations) { throw new NotFoundException(`Reservations with order ID ${orderId} not found`); } for (const reservation of reservations) { reservation.status = ReservationStatus.CONFIRMED; } - await this.em.flush(); return reservations; } + + async releaseReservationByOrderId(em: EntityManager, orderId: string): Promise { + const reservations = await em.find(Reservation, { order: { id: orderId } }); + if (!reservations) { + throw new NotFoundException(`Reservations with order ID ${orderId} not found`); + } + for (const reservation of reservations) { + em.remove(reservation); + } + return reservations; + } + + getReservationExpiry(method: PaymentMethodEnum): Date { + const minutes = STOCK_RESERVE_MINUTES[method] ?? 15; + return new Date(Date.now() + minutes * 60 * 1000); + } + } diff --git a/src/modules/orders/controllers/orders.controller.ts b/src/modules/orders/controllers/orders.controller.ts index ca805f7..d619517 100644 --- a/src/modules/orders/controllers/orders.controller.ts +++ b/src/modules/orders/controllers/orders.controller.ts @@ -6,7 +6,7 @@ import { UserId } from '../../../common/decorators/user-id.decorator'; import { RestId } from 'src/common/decorators/rest-id.decorator'; import { AdminAuthGuard } from '../../auth/guards/adminAuth.guard'; import { FindOrdersDto } from '../dto/find-orders.dto'; -import { OrderStatus } from '../interface/order-status'; +import { OrderStatus } from '../interface/order.interface'; @ApiTags('orders') @ApiBearerAuth() diff --git a/src/modules/orders/dto/find-orders.dto.ts b/src/modules/orders/dto/find-orders.dto.ts index 184dfd0..44714f6 100644 --- a/src/modules/orders/dto/find-orders.dto.ts +++ b/src/modules/orders/dto/find-orders.dto.ts @@ -1,7 +1,7 @@ import { IsOptional, IsString, IsNumber, Min, IsIn, IsEnum, IsDateString } from 'class-validator'; import { Type } from 'class-transformer'; import { ApiPropertyOptional } from '@nestjs/swagger'; -import { OrderStatus } from '../interface/order-status'; +import { OrderStatus } from '../interface/order.interface'; import { PaymentStatusEnum } from '../../payments/interface/payment'; // Define the valid sort directions diff --git a/src/modules/orders/entities/order.entity.ts b/src/modules/orders/entities/order.entity.ts index c2ed379..e80e81f 100644 --- a/src/modules/orders/entities/order.entity.ts +++ b/src/modules/orders/entities/order.entity.ts @@ -13,13 +13,13 @@ import { } from '@mikro-orm/core'; import { BaseEntity } from '../../../common/entities/base.entity'; import { PaymentStatusEnum } from '../../payments/interface/payment'; -import { OrderCouponDetail, OrderStatus } from '../interface/order-status'; +import { OrderCouponDetail, OrderStatus } from '../interface/order.interface'; import { User } from '../../users/entities/user.entity'; import { Restaurant } from '../../restaurants/entities/restaurant.entity'; import { PaymentMethod } from '../../payments/entities/payment-method.entity'; import { OrderItem } from './order-item.entity'; import { Delivery } from '../../delivery/entities/delivery.entity'; -import { OrderUserAddress, OrderCarAddress } from '../interface/order-status'; +import { OrderUserAddress, OrderCarAddress } from '../interface/order.interface'; @Entity({ tableName: 'orders' }) @Unique({ properties: ['restaurant', 'orderNumber'] }) diff --git a/src/modules/orders/interface/order-status.ts b/src/modules/orders/interface/order.interface.ts similarity index 100% rename from src/modules/orders/interface/order-status.ts rename to src/modules/orders/interface/order.interface.ts diff --git a/src/modules/orders/orders.module.ts b/src/modules/orders/orders.module.ts index a64387e..04df38d 100644 --- a/src/modules/orders/orders.module.ts +++ b/src/modules/orders/orders.module.ts @@ -18,6 +18,7 @@ import { OrderRepository } from './repositories/order.repository'; import { OrderListeners } from './listeners/order.listeners'; import { AdminModule } from '../admin/admin.module'; import { NotificationsModule } from '../notifications/notifications.module'; +import { InventoryModule } from '../inventory/inventory.module'; @Module({ imports: [ @@ -29,6 +30,7 @@ import { NotificationsModule } from '../notifications/notifications.module'; JwtModule, AdminModule, NotificationsModule, + InventoryModule ], controllers: [OrdersController], providers: [OrdersService, OrderRepository, OrderListeners], diff --git a/src/modules/orders/providers/orders.service.ts b/src/modules/orders/providers/orders.service.ts index 74e004e..817d6f8 100644 --- a/src/modules/orders/providers/orders.service.ts +++ b/src/modules/orders/providers/orders.service.ts @@ -6,7 +6,7 @@ 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-status'; +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'; @@ -17,6 +17,8 @@ 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 }; @@ -53,6 +55,7 @@ export class OrdersService { private readonly cartService: CartService, private readonly orderRepository: OrderRepository, private readonly paymentsService: PaymentsService, + private readonly inventoryService: InventoryService, ) { } async checkout(userId: string, restaurantId: string) { @@ -109,8 +112,16 @@ export class OrdersService { method: order.paymentMethod.method, gateway: order.paymentMethod.gateway ?? null, }); - em.persist(payment); + 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.tempBulkReserveFood(em, restaurantId, order.id, order.paymentMethod.method, bulkReserveFoodDto); await em.flush(); this.logger.debug(`Order ${order.id} created for user ${userId} (restaurant ${restaurantId})`); return order; diff --git a/src/modules/orders/repositories/order.repository.ts b/src/modules/orders/repositories/order.repository.ts index 0db34f8..d27fcf9 100644 --- a/src/modules/orders/repositories/order.repository.ts +++ b/src/modules/orders/repositories/order.repository.ts @@ -3,7 +3,7 @@ import { EntityManager, EntityRepository } from '@mikro-orm/postgresql'; import { FilterQuery } from '@mikro-orm/core'; import { Order } from '../entities/order.entity'; import { PaginatedResult } from 'src/common/interfaces/pagination.interface'; -import { OrderStatus } from '../interface/order-status'; +import { OrderStatus } from '../interface/order.interface'; import { PaymentStatusEnum } from '../../payments/interface/payment'; import { Review } from '../../review/entities/review.entity'; @@ -96,7 +96,7 @@ export class OrderRepository extends EntityRepository { limit, offset, orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' }, - populate: ['user', 'restaurant', 'deliveryMethod', 'paymentMethod', 'items', 'items.food'] as never, + populate: ['user', 'restaurant', 'deliveryMethod', 'paymentMethod', 'items', 'items.food'] as never, }); // Collect all (orderId, foodId) pairs for efficient review lookup diff --git a/src/modules/payments/payments.module.ts b/src/modules/payments/payments.module.ts index 899f096..e8b2fa3 100644 --- a/src/modules/payments/payments.module.ts +++ b/src/modules/payments/payments.module.ts @@ -12,11 +12,14 @@ import { Payment } from './entities/payment.entity'; import { ZarinpalGateway } from './gateways/zarinpal.gateway'; import { GatewayManager } from './services/gateway.manager'; import { PaymentRepository } from './repositories/payment.repository'; +import { InventoryModule } from '../inventory/inventory.module'; @Module({ - imports: [MikroOrmModule.forFeature([PaymentMethod, Payment, Restaurant]), AuthModule, JwtModule], + imports: [MikroOrmModule.forFeature([PaymentMethod, Payment, Restaurant]), + AuthModule, JwtModule, InventoryModule], controllers: [PaymentsController], - providers: [PaymentsService, PaymentMethodService, PaymentMethodRepository, PaymentRepository, ZarinpalGateway, GatewayManager], + providers: [PaymentsService, PaymentMethodService, PaymentMethodRepository, + PaymentRepository, ZarinpalGateway, GatewayManager], exports: [ PaymentMethodRepository, PaymentRepository, diff --git a/src/modules/payments/services/payments.service.ts b/src/modules/payments/services/payments.service.ts index 4ff0d08..8d40e63 100644 --- a/src/modules/payments/services/payments.service.ts +++ b/src/modules/payments/services/payments.service.ts @@ -1,12 +1,14 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { PaymentGatewayEnum, PaymentMethodEnum, PaymentStatusEnum } from '../interface/payment'; import { Payment } from '../entities/payment.entity'; -import { EntityManager } from '@mikro-orm/core'; +import { EntityManager } from '@mikro-orm/postgresql'; import { Order } from '../../orders/entities/order.entity'; import { Logger } from '@nestjs/common'; import { GatewayManager } from './gateway.manager'; import { UserWallet } from 'src/modules/users/entities/user-wallet.entity'; import { OrderPaymentContext } from '../interface/payment'; +import { InventoryService } from 'src/modules/inventory/inventory.service'; +import { OrderStatus } from 'src/modules/orders/interface/order.interface'; @Injectable() export class PaymentsService { @@ -15,6 +17,7 @@ export class PaymentsService { constructor( private readonly em: EntityManager, private readonly gatewayManager: GatewayManager, + private readonly inventoryService: InventoryService, ) { } async payOrder( @@ -201,16 +204,12 @@ export class PaymentsService { payment.verifyResponse = result.raw; if (!result.success) { - payment.status = PaymentStatusEnum.Failed; - payment.failedAt = new Date(); - payment.order.paymentStatus = PaymentStatusEnum.Failed; - } else { - payment.status = PaymentStatusEnum.Paid; - payment.referenceId = result.referenceId; - payment.cardPan = result.cardPan; - payment.paidAt = new Date(); - payment.order.paymentStatus = PaymentStatusEnum.Paid; + this.failPayment(payment); + return payment; } + this.markPaid(payment); + this.confirmOrder(payment.order); + await this.confirmStock(orderId, em); await em.flush(); return payment; @@ -225,6 +224,28 @@ export class PaymentsService { ); } + private markPaid(payment: Payment) { + payment.status = PaymentStatusEnum.Paid; + payment.paidAt = new Date(); + payment.referenceId = payment.referenceId; + payment.order.paymentStatus = PaymentStatusEnum.Paid; + } + + private failPayment(payment: Payment) { + payment.status = PaymentStatusEnum.Failed; + payment.failedAt = new Date(); + payment.order.paymentStatus = PaymentStatusEnum.Failed; + } + + private confirmOrder(order: Order) { + order.status = OrderStatus.PAID; + } + + private async confirmStock(orderId: string, em: EntityManager) { + await this.inventoryService.confirmReservationByOrderId(em, orderId); + } + + private async getOrCreateLatestPendingPayment( orderId: string, params: {