diff --git a/.vscode/settings.json b/.vscode/settings.json index 28ec25e..773cd7b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,6 +7,6 @@ "source.fixAll.eslint": "explicit" }, "[typescript]": { - "editor.defaultFormatter": "vscode.typescript-language-features" + "editor.defaultFormatter": "esbenp.prettier-vscode" } } diff --git a/src/modules/inventory/dto/bulk-reserve-food.dto.ts b/src/modules/inventory/dto/bulk-reserve-food.dto.ts index 5af153b..d8ce346 100644 --- a/src/modules/inventory/dto/bulk-reserve-food.dto.ts +++ b/src/modules/inventory/dto/bulk-reserve-food.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsArray, ValidateNested, ArrayMinSize, IsString, IsNumber, Min, IsDate } from 'class-validator'; +import { IsNotEmpty, IsArray, ValidateNested, ArrayMinSize, IsString, IsNumber, Min } from 'class-validator'; import { Type } from 'class-transformer'; export class BulkReserveFoodItemDto { @@ -14,7 +14,6 @@ export class BulkReserveFoodItemDto { @Min(1) @Type(() => Number) quantity!: number; - } export class BulkReserveFoodDto { @@ -22,7 +21,7 @@ export class BulkReserveFoodDto { description: 'Array of food reservations to create', type: [BulkReserveFoodItemDto], example: [ - { foodId: 'food-123', quantity: 5 }, + { foodId: 'food-123', quantity: 5 }, { foodId: 'food-789', quantity: 3 }, ], }) diff --git a/src/modules/inventory/inventory.service.ts b/src/modules/inventory/inventory.service.ts index 6926d08..8fcfa1f 100644 --- a/src/modules/inventory/inventory.service.ts +++ b/src/modules/inventory/inventory.service.ts @@ -6,12 +6,10 @@ import { BulkReserveFoodDto } from './dto/bulk-reserve-food.dto'; import { Inventory } from './entities/inventory.entity'; import { Food } from '../foods/entities/food.entity'; import { Restaurant } from '../restaurants/entities/restaurant.entity'; -import { LockMode } from '@mikro-orm/core'; - @Injectable() export class InventoryService { - constructor(private readonly em: EntityManager) { } + constructor(private readonly em: EntityManager) {} async setStockForFood(foodId: string, restaurantId: string, setStockDto: SetStockDto): Promise { // Validate that availableStock doesn't exceed totalStock @@ -58,7 +56,6 @@ export class InventoryService { } async bulkSetStockForFoods(restaurantId: string, bulkSetStockDto: BulkSetStockDto): Promise { - const { items } = bulkSetStockDto; // Validate all items first for (const item of items) { @@ -132,12 +129,8 @@ export class InventoryService { return results; } - async deductFromInventory( - em: EntityManager, - bulkReserveFoodDto: BulkReserveFoodDto, - ): Promise { + async deductFromInventory(em: EntityManager, bulkReserveFoodDto: BulkReserveFoodDto): Promise { return em.transactional(async em => { - const { items } = bulkReserveFoodDto; // Get all unique food IDs const foodIds = [...new Set(items.map(item => item.foodId))]; @@ -189,7 +182,46 @@ export class InventoryService { } return inventories; }); - } + async restoreToInventory(em: EntityManager, bulkRestoreDto: BulkReserveFoodDto): Promise { + return em.transactional(async em => { + const { items } = bulkRestoreDto; + + const foodIds = [...new Set(items.map(item => item.foodId))]; + + const foods = await em.find(Food, { id: { $in: foodIds } }); + const foodMap = new Map(); + for (const food of foods) { + foodMap.set(food.id, food); + } + + const missingFoodIds = foodIds.filter(id => !foodMap.has(id)); + if (missingFoodIds.length > 0) { + throw new NotFoundException(`Foods not found: ${missingFoodIds.join(', ')}`); + } + + const existingInventories = await em.find(Inventory, { + food: { id: { $in: foodIds } }, + }); + + const inventoryMap = new Map(); + for (const inventory of existingInventories) { + inventoryMap.set(inventory.food.id, inventory); + } + + const inventories: Inventory[] = []; + for (const item of items) { + const inventory = inventoryMap.get(item.foodId); + if (!inventory) { + throw new NotFoundException(`Inventory not found for food ${item.foodId}`); + } + inventory.availableStock += item.quantity; + inventories.push(inventory); + em.persist(inventory); + } + + return inventories; + }); + } } diff --git a/src/modules/orders/crone/order.crone.ts b/src/modules/orders/crone/order.crone.ts index 7886d6e..7e5c152 100644 --- a/src/modules/orders/crone/order.crone.ts +++ b/src/modules/orders/crone/order.crone.ts @@ -1,6 +1,89 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { Payment } from '../../payments/entities/payment.entity'; +import { PaymentMethodEnum, PaymentStatusEnum } from '../../payments/interface/payment'; +import { InventoryService } from '../../inventory/inventory.service'; +import { OrderStatus } from '../interface/order.interface'; @Injectable() export class OrdersCrone { + private readonly logger = new Logger(OrdersCrone.name); -} \ No newline at end of file + constructor( + private readonly em: EntityManager, + private readonly inventoryService: InventoryService, + ) {} + + // run every minute and fail pending online payments older than 15 minutes + @Cron('*/1 * * * *', { + name: 'failOldOnlinePayments', + timeZone: 'UTC', + }) + async handleCron() { + try { + const cutoff = new Date(Date.now() - 15 * 60 * 1000); + + this.logger.debug('Searching for pending online payments older than 15 minutes'); + + const payments = await this.em.find( + Payment, + { + method: PaymentMethodEnum.Online, + status: PaymentStatusEnum.Pending, + createdAt: { $lte: cutoff }, + }, + { populate: ['order', 'order.items', 'order.items.food'] }, + ); + + if (!payments || payments.length === 0) { + return; + } + + this.logger.log(`Found ${payments.length} stale pending online payments`); + + for (const p of payments) { + try { + await this.em.transactional(async em => { + // reload inside transaction to avoid concurrency issues + const payment = await em.findOne( + Payment, + { id: p.id }, + { populate: ['order', 'order.items', 'order.items.food'] }, + ); + if (!payment) return; + if (payment.status !== PaymentStatusEnum.Pending) return; + + payment.status = PaymentStatusEnum.Failed; + payment.failedAt = new Date(); + + if (payment.order) { + payment.order.status = OrderStatus.FAILED; + + // prepare restore payload + const items = (payment.order as any).items || []; + const restorePayload = { + items: items.map((it: any) => ({ foodId: it.food.id, quantity: it.quantity })), + }; + + if (restorePayload.items.length > 0) { + await this.inventoryService.restoreToInventory(em, restorePayload); + } + } + + em.persist(payment); + if (payment.order) em.persist(payment.order); + await em.flush(); + this.logger.log( + `Marked payment ${payment.id} and order ${payment.order?.id} as failed and restored inventory`, + ); + }); + } catch (err) { + this.logger.error(`Error processing payment ${p.id}: ${err.message}`, err.stack); + } + } + } catch (err) { + this.logger.error(`OrdersCrone failed: ${err.message}`, err.stack); + } + } +} diff --git a/src/modules/orders/entities/order.entity.ts b/src/modules/orders/entities/order.entity.ts index e80e81f..aa37811 100644 --- a/src/modules/orders/entities/order.entity.ts +++ b/src/modules/orders/entities/order.entity.ts @@ -12,7 +12,6 @@ import { type EventArgs, } from '@mikro-orm/core'; import { BaseEntity } from '../../../common/entities/base.entity'; -import { PaymentStatusEnum } from '../../payments/interface/payment'; import { OrderCouponDetail, OrderStatus } from '../interface/order.interface'; import { User } from '../../users/entities/user.entity'; import { Restaurant } from '../../restaurants/entities/restaurant.entity'; @@ -27,7 +26,6 @@ import { OrderUserAddress, OrderCarAddress } from '../interface/order.interface' @Index({ properties: ['user', 'status'] }) @Index({ properties: ['restaurant', 'orderNumber'] }) @Index({ properties: ['status'] }) -@Index({ properties: ['paymentStatus'] }) export class Order extends BaseEntity { @ManyToOne(() => User) user!: User; @@ -92,8 +90,8 @@ export class Order extends BaseEntity { @Enum(() => OrderStatus) status!: OrderStatus; - @Enum(() => PaymentStatusEnum) - paymentStatus!: PaymentStatusEnum; + // @Enum(() => PaymentStatusEnum) + // paymentStatus!: PaymentStatusEnum; @BeforeCreate() async generateOrderNumber(args: EventArgs) { diff --git a/src/modules/orders/interface/order.interface.ts b/src/modules/orders/interface/order.interface.ts index 7f87835..0af28c8 100644 --- a/src/modules/orders/interface/order.interface.ts +++ b/src/modules/orders/interface/order.interface.ts @@ -19,7 +19,6 @@ export interface OrderCarAddress { } export enum OrderStatus { - NEW = 'new', PENDING_PAYMENT = 'pendingPayment', PAID = 'paid', CONFIRMED = 'confirmed', diff --git a/src/modules/orders/orders.module.ts b/src/modules/orders/orders.module.ts index f941afc..e4eaf69 100644 --- a/src/modules/orders/orders.module.ts +++ b/src/modules/orders/orders.module.ts @@ -16,10 +16,11 @@ import { PaymentsModule } from '../payments/payments.module'; import { JwtModule } from '@nestjs/jwt'; import { OrderRepository } from './repositories/order.repository'; import { OrderListeners } from './listeners/order.listeners'; +import { OrdersCrone } from './crone/order.crone'; import { AdminModule } from '../admin/admin.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { InventoryModule } from '../inventory/inventory.module'; - + @Module({ imports: [ MikroOrmModule.forFeature([Order, OrderItem, User, Restaurant, Food, UserAddress, PaymentMethod]), @@ -33,7 +34,7 @@ import { InventoryModule } from '../inventory/inventory.module'; InventoryModule ], controllers: [OrdersController], - providers: [OrdersService, OrderRepository, OrderListeners], + providers: [OrdersService, OrderRepository, OrderListeners, OrdersCrone], exports: [OrderRepository], }) -export class OrdersModule {} +export class OrdersModule { } diff --git a/src/modules/payments/interface/payment.ts b/src/modules/payments/interface/payment.ts index 7f07883..6fd70c2 100644 --- a/src/modules/payments/interface/payment.ts +++ b/src/modules/payments/interface/payment.ts @@ -1,4 +1,4 @@ -import type{ Order } from "src/modules/orders/entities/order.entity"; +import type { Order } from 'src/modules/orders/entities/order.entity'; export enum PaymentMethodEnum { Online = 'Online', @@ -9,6 +9,7 @@ export enum PaymentStatusEnum { Pending = 'pending', Paid = 'paid', Failed = 'failed', + Refunded = 'refunded', } export enum PaymentGatewayEnum { ZarinPal = 'zarinpal',