crone for failed payment orders
This commit is contained in:
Vendored
+1
-1
@@ -7,6 +7,6 @@
|
|||||||
"source.fixAll.eslint": "explicit"
|
"source.fixAll.eslint": "explicit"
|
||||||
},
|
},
|
||||||
"[typescript]": {
|
"[typescript]": {
|
||||||
"editor.defaultFormatter": "vscode.typescript-language-features"
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
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';
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
export class BulkReserveFoodItemDto {
|
export class BulkReserveFoodItemDto {
|
||||||
@@ -14,7 +14,6 @@ export class BulkReserveFoodItemDto {
|
|||||||
@Min(1)
|
@Min(1)
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
quantity!: number;
|
quantity!: number;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BulkReserveFoodDto {
|
export class BulkReserveFoodDto {
|
||||||
@@ -22,7 +21,7 @@ export class BulkReserveFoodDto {
|
|||||||
description: 'Array of food reservations to create',
|
description: 'Array of food reservations to create',
|
||||||
type: [BulkReserveFoodItemDto],
|
type: [BulkReserveFoodItemDto],
|
||||||
example: [
|
example: [
|
||||||
{ foodId: 'food-123', quantity: 5 },
|
{ foodId: 'food-123', quantity: 5 },
|
||||||
{ foodId: 'food-789', quantity: 3 },
|
{ foodId: 'food-789', quantity: 3 },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,12 +6,10 @@ import { BulkReserveFoodDto } from './dto/bulk-reserve-food.dto';
|
|||||||
import { Inventory } from './entities/inventory.entity';
|
import { Inventory } from './entities/inventory.entity';
|
||||||
import { Food } from '../foods/entities/food.entity';
|
import { Food } from '../foods/entities/food.entity';
|
||||||
import { Restaurant } from '../restaurants/entities/restaurant.entity';
|
import { Restaurant } from '../restaurants/entities/restaurant.entity';
|
||||||
import { LockMode } from '@mikro-orm/core';
|
|
||||||
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InventoryService {
|
export class InventoryService {
|
||||||
constructor(private readonly em: EntityManager) { }
|
constructor(private readonly em: EntityManager) {}
|
||||||
|
|
||||||
async setStockForFood(foodId: string, restaurantId: string, setStockDto: SetStockDto): Promise<Inventory> {
|
async setStockForFood(foodId: string, restaurantId: string, setStockDto: SetStockDto): Promise<Inventory> {
|
||||||
// Validate that availableStock doesn't exceed totalStock
|
// Validate that availableStock doesn't exceed totalStock
|
||||||
@@ -58,7 +56,6 @@ export class InventoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async bulkSetStockForFoods(restaurantId: string, bulkSetStockDto: BulkSetStockDto): Promise<Inventory[]> {
|
async bulkSetStockForFoods(restaurantId: string, bulkSetStockDto: BulkSetStockDto): Promise<Inventory[]> {
|
||||||
|
|
||||||
const { items } = bulkSetStockDto;
|
const { items } = bulkSetStockDto;
|
||||||
// Validate all items first
|
// Validate all items first
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
@@ -132,12 +129,8 @@ export class InventoryService {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deductFromInventory(
|
async deductFromInventory(em: EntityManager, bulkReserveFoodDto: BulkReserveFoodDto): Promise<Inventory[]> {
|
||||||
em: EntityManager,
|
|
||||||
bulkReserveFoodDto: BulkReserveFoodDto,
|
|
||||||
): Promise<Inventory[]> {
|
|
||||||
return em.transactional(async em => {
|
return em.transactional(async em => {
|
||||||
|
|
||||||
const { items } = bulkReserveFoodDto;
|
const { items } = bulkReserveFoodDto;
|
||||||
// Get all unique food IDs
|
// Get all unique food IDs
|
||||||
const foodIds = [...new Set(items.map(item => item.foodId))];
|
const foodIds = [...new Set(items.map(item => item.foodId))];
|
||||||
@@ -189,7 +182,46 @@ export class InventoryService {
|
|||||||
}
|
}
|
||||||
return inventories;
|
return inventories;
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async restoreToInventory(em: EntityManager, bulkRestoreDto: BulkReserveFoodDto): Promise<Inventory[]> {
|
||||||
|
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<string, Food>();
|
||||||
|
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<string, Inventory>();
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
@Injectable()
|
||||||
export class OrdersCrone {
|
export class OrdersCrone {
|
||||||
|
private readonly logger = new Logger(OrdersCrone.name);
|
||||||
|
|
||||||
}
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
type EventArgs,
|
type EventArgs,
|
||||||
} from '@mikro-orm/core';
|
} from '@mikro-orm/core';
|
||||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||||
import { PaymentStatusEnum } from '../../payments/interface/payment';
|
|
||||||
import { OrderCouponDetail, OrderStatus } from '../interface/order.interface';
|
import { OrderCouponDetail, OrderStatus } from '../interface/order.interface';
|
||||||
import { User } from '../../users/entities/user.entity';
|
import { User } from '../../users/entities/user.entity';
|
||||||
import { Restaurant } from '../../restaurants/entities/restaurant.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: ['user', 'status'] })
|
||||||
@Index({ properties: ['restaurant', 'orderNumber'] })
|
@Index({ properties: ['restaurant', 'orderNumber'] })
|
||||||
@Index({ properties: ['status'] })
|
@Index({ properties: ['status'] })
|
||||||
@Index({ properties: ['paymentStatus'] })
|
|
||||||
export class Order extends BaseEntity {
|
export class Order extends BaseEntity {
|
||||||
@ManyToOne(() => User)
|
@ManyToOne(() => User)
|
||||||
user!: User;
|
user!: User;
|
||||||
@@ -92,8 +90,8 @@ export class Order extends BaseEntity {
|
|||||||
@Enum(() => OrderStatus)
|
@Enum(() => OrderStatus)
|
||||||
status!: OrderStatus;
|
status!: OrderStatus;
|
||||||
|
|
||||||
@Enum(() => PaymentStatusEnum)
|
// @Enum(() => PaymentStatusEnum)
|
||||||
paymentStatus!: PaymentStatusEnum;
|
// paymentStatus!: PaymentStatusEnum;
|
||||||
|
|
||||||
@BeforeCreate()
|
@BeforeCreate()
|
||||||
async generateOrderNumber(args: EventArgs<Order>) {
|
async generateOrderNumber(args: EventArgs<Order>) {
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ export interface OrderCarAddress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export enum OrderStatus {
|
export enum OrderStatus {
|
||||||
NEW = 'new',
|
|
||||||
PENDING_PAYMENT = 'pendingPayment',
|
PENDING_PAYMENT = 'pendingPayment',
|
||||||
PAID = 'paid',
|
PAID = 'paid',
|
||||||
CONFIRMED = 'confirmed',
|
CONFIRMED = 'confirmed',
|
||||||
|
|||||||
@@ -16,10 +16,11 @@ import { PaymentsModule } from '../payments/payments.module';
|
|||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { OrderRepository } from './repositories/order.repository';
|
import { OrderRepository } from './repositories/order.repository';
|
||||||
import { OrderListeners } from './listeners/order.listeners';
|
import { OrderListeners } from './listeners/order.listeners';
|
||||||
|
import { OrdersCrone } from './crone/order.crone';
|
||||||
import { AdminModule } from '../admin/admin.module';
|
import { AdminModule } from '../admin/admin.module';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { InventoryModule } from '../inventory/inventory.module';
|
import { InventoryModule } from '../inventory/inventory.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
MikroOrmModule.forFeature([Order, OrderItem, User, Restaurant, Food, UserAddress, PaymentMethod]),
|
MikroOrmModule.forFeature([Order, OrderItem, User, Restaurant, Food, UserAddress, PaymentMethod]),
|
||||||
@@ -33,7 +34,7 @@ import { InventoryModule } from '../inventory/inventory.module';
|
|||||||
InventoryModule
|
InventoryModule
|
||||||
],
|
],
|
||||||
controllers: [OrdersController],
|
controllers: [OrdersController],
|
||||||
providers: [OrdersService, OrderRepository, OrderListeners],
|
providers: [OrdersService, OrderRepository, OrderListeners, OrdersCrone],
|
||||||
exports: [OrderRepository],
|
exports: [OrderRepository],
|
||||||
})
|
})
|
||||||
export class OrdersModule {}
|
export class OrdersModule { }
|
||||||
|
|||||||
@@ -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 {
|
export enum PaymentMethodEnum {
|
||||||
Online = 'Online',
|
Online = 'Online',
|
||||||
@@ -9,6 +9,7 @@ export enum PaymentStatusEnum {
|
|||||||
Pending = 'pending',
|
Pending = 'pending',
|
||||||
Paid = 'paid',
|
Paid = 'paid',
|
||||||
Failed = 'failed',
|
Failed = 'failed',
|
||||||
|
Refunded = 'refunded',
|
||||||
}
|
}
|
||||||
export enum PaymentGatewayEnum {
|
export enum PaymentGatewayEnum {
|
||||||
ZarinPal = 'zarinpal',
|
ZarinPal = 'zarinpal',
|
||||||
|
|||||||
Reference in New Issue
Block a user