refactor order craetion

This commit is contained in:
2025-12-17 15:36:04 +03:30
parent 26220dbb8b
commit bfb7441a0c
7 changed files with 180 additions and 78 deletions
+28 -27
View File
@@ -16,7 +16,7 @@ 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 { EventEmitter2 } from '@nestjs/event-emitter';
import { Payment } from 'src/modules/payments/entities/payment.entity';
type OrderItemData = { food: Food; quantity: number; unitPrice: number; discount: number };
@@ -52,34 +52,21 @@ export class OrdersService {
private readonly em: EntityManager,
private readonly cartService: CartService,
private readonly orderRepository: OrderRepository,
// NOTE: kept for future payment integration
private readonly _paymentsService: PaymentsService,
// NOTE: kept for future event emission
private readonly _eventEmitter2: EventEmitter2,
private readonly paymentsService: PaymentsService,
) { }
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.createOrder(userId, restaurantId, cart);
await this.cartService.clearCart(userId, restaurantId);
this.logger.debug(`Order ${order.id} created for user ${userId} (restaurant ${restaurantId})`);
return { order };
}
async createOrder(userId: string, restaurantId: string, cart: Cart) {
const validationResult = await this.validateCartForOrder(userId, restaurantId, cart);
return this.em.transactional(async em => {
const order = await this.em.transactional(async em => {
const order = em.create(Order, {
user: validationResult.user,
restaurant: validationResult.restaurant,
deliveryMethod: validationResult.delivery,
userAddress: validationResult.userAddress,
carAddress: validationResult.carAddress,
paymentMethod: validationResult.paymentMethod,
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,
@@ -96,7 +83,7 @@ export class OrdersService {
em.persist(order);
for (const itemData of validationResult.orderItemsData) {
for (const itemData of validated.orderItemsData) {
const { food, quantity, unitPrice, discount } = itemData;
this.assertFoodHasSufficientStock(food, quantity);
@@ -113,13 +100,27 @@ export class OrdersService {
});
em.persist(orderItem);
em.persist(food);
}
const payment = em.create(Payment, {
order,
amount: order.total,
status: PaymentStatusEnum.Pending,
method: order.paymentMethod.method,
gateway: order.paymentMethod.gateway ?? null,
});
em.persist(payment);
await em.flush();
return { order };
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 };
}
/**
@@ -67,8 +67,8 @@ export class PaymentsController {
name: 'X-Slug',
required: true,
})
payAnOrder(@Param('orderId') orderId: string, @RestId() restId: string) {
return this.paymentsService.payOrder(orderId, restId);
payAnOrder(@Param('orderId') orderId: string) {
return this.paymentsService.payOrder(orderId);
}
// @UseGuards(AdminAuthGuard)
// @ApiBearerAuth()
@@ -33,6 +33,11 @@ export class ZarinpalGateway implements IPaymentGateway {
this.zarinpalVerifyUrl = `${zarinpalBaseUrl}/pg/v4/payment/verify.json`;
}
private getAxiosErrorData(error: unknown): { status?: number; data?: unknown } | null {
if (!axios.isAxiosError(error) || !error.response) return null;
return { status: error.response.status, data: error.response.data };
}
async requestPayment({ amount, merchantId, orderId, domain }: IRequestPaymentParams): Promise<IRequestPaymentData> {
// Transform camelCase to snake_case for Zarinpal API v4
const callbackUrl = `${domain}/verify/${orderId}`;
@@ -48,23 +53,53 @@ export class ZarinpalGateway implements IPaymentGateway {
try {
const res = await axios.post<IZarinpalPaymentResponse>(this.zarinpalRequestUrl, zarinpalRequest, this.axiosConfig);
const { code, message, authority, errors } = res.data;
// Check if there are errors in the response
if ((code !== 100 && code !== 0) || !authority) {
const { data, errors } = res.data ?? {};
const code = data?.code;
const message = data?.message;
const authority = data?.authority;
if (!data) {
this.logger.error('Invalid response from Zarinpal API (missing data)', JSON.stringify(res.data));
throw new BadRequestException('Invalid response from Zarinpal API');
}
if ((Array.isArray(errors) && errors.length > 0) || code !== 100 || !authority) {
this.logger.error(
'Zarinpal payment request failed',
JSON.stringify({
code,
message,
errors,
callbackUrl,
amount,
orderId,
}),
);
throw new BadRequestException(message ?? 'Zarinpal payment request error');
}
return { transactionId: authority };
} catch (error) {
// Log the actual API error response for debugging
if (axios.isAxiosError(error) && error.response) {
this.logger.error('Zarinpal API error response', {
status: error.response.status,
data: JSON.stringify(error.response.data),
request: zarinpalRequest,
});
if (error instanceof BadRequestException) throw error;
const axiosErr = this.getAxiosErrorData(error);
if (axiosErr) {
this.logger.error(
'Zarinpal payment request axios error',
JSON.stringify({
status: axiosErr.status,
data: axiosErr.data,
callbackUrl,
amount,
orderId,
}),
);
throw new BadRequestException('Zarinpal payment request error');
}
throw error;
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
this.logger.error('Zarinpal payment request error', errorMessage);
throw new BadRequestException(`Failed to request payment from gateway: ${errorMessage}`);
}
}
+9 -7
View File
@@ -41,12 +41,14 @@ export interface IZarinpalRequestPayment {
referrer_id?: string;
}
export interface IZarinpalPaymentResponse {
code: number;
message: string;
authority: string;
fee_type: string;
fee: number;
errors: [];
data: {
code: number;
message: string;
authority: string;
fee_type: string;
fee: number;
};
errors: unknown[];
}
export interface IZarinpalVerifyRequest {
merchant_id: string;
@@ -63,5 +65,5 @@ export interface IZarinpalVerifyResponse {
fee_type: string;
fee: number;
};
errors: [];
errors: unknown[];
}
+3 -1
View File
@@ -11,13 +11,15 @@ import { JwtModule } from '@nestjs/jwt';
import { Payment } from './entities/payment.entity';
import { ZarinpalGateway } from './gateways/zarinpal.gateway';
import { GatewayManager } from './services/gateway.manager';
import { PaymentRepository } from './repositories/payment.repository';
@Module({
imports: [MikroOrmModule.forFeature([PaymentMethod, Payment, Restaurant]), AuthModule, JwtModule],
controllers: [PaymentsController],
providers: [PaymentsService, PaymentMethodService, PaymentMethodRepository, ZarinpalGateway, GatewayManager],
providers: [PaymentsService, PaymentMethodService, PaymentMethodRepository, PaymentRepository, ZarinpalGateway, GatewayManager],
exports: [
PaymentMethodRepository,
PaymentRepository,
PaymentMethodService,
PaymentsService,
// PaymentGatewayService,
@@ -0,0 +1,10 @@
import { Injectable } from '@nestjs/common';
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
import { Payment } from '../entities/payment.entity';
@Injectable()
export class PaymentRepository extends EntityRepository<Payment> {
constructor(readonly em: EntityManager) {
super(em, Payment);
}
}
@@ -1,5 +1,5 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { PaymentMethodEnum, PaymentStatusEnum } from '../interface/payment';
import { PaymentGatewayEnum, PaymentMethodEnum, PaymentStatusEnum } from '../interface/payment';
import { Payment } from '../entities/payment.entity';
import { EntityManager } from '@mikro-orm/core';
import { Order } from '../../orders/entities/order.entity';
@@ -19,9 +19,13 @@ export class PaymentsService {
async payOrder(
orderId: string,
restId: string,
): Promise<{ paymentUrl: string | null }> {
const ctx = await this.loadAndValidateOrder(orderId, restId);
const ctx = await this.loadAndValidateOrder(orderId);
// Idempotency: avoid creating/charging again for already-paid orders
if (ctx.order.paymentStatus === PaymentStatusEnum.Paid) {
return { paymentUrl: null };
}
switch (ctx.method) {
case PaymentMethodEnum.Cash:
@@ -42,12 +46,11 @@ export class PaymentsService {
private async loadAndValidateOrder(
orderId: string,
restId: string,
): Promise<OrderPaymentContext> {
const order = await this.em.findOne(
Order,
{ id: orderId, restaurant: { id: restId } },
{ populate: ['user', 'paymentMethod', 'paymentMethod.restaurant'] },
{ id: orderId },
{ populate: ['user', 'restaurant', 'paymentMethod', 'paymentMethod.restaurant'] },
);
if (!order) {
@@ -63,8 +66,10 @@ export class PaymentsService {
throw new NotFoundException('Payment method not found');
}
if (pm.method === PaymentMethodEnum.Online && !pm.merchantId) {
throw new BadRequestException('Merchant ID is required for online payment');
if (pm.method === PaymentMethodEnum.Online) {
if (!pm.gateway) throw new BadRequestException('Gateway is required for online payment');
if (!pm.merchantId) throw new BadRequestException('Merchant ID is required for online payment');
if (!pm.restaurant?.domain) throw new BadRequestException('Restaurant domain is required for online payment');
}
return {
@@ -78,23 +83,25 @@ export class PaymentsService {
}
private async handleCashPayment(ctx: OrderPaymentContext): Promise<void> {
const payment = this.em.create(Payment, {
await this.getOrCreateLatestPendingPayment(ctx.order.id, {
amount: ctx.amount,
order: ctx.order,
method: PaymentMethodEnum.Cash,
gateway: null,
transactionId: null,
status: PaymentStatusEnum.Pending,
});
await this.em.persistAndFlush(payment);
}
private async handleWalletPayment(ctx: OrderPaymentContext): Promise<void> {
await this.em.transactional(async em => {
const order = await em.findOne(Order, { id: ctx.order.id }, { populate: ['user', 'restaurant'] });
if (!order) throw new NotFoundException('Order not found');
if (order.paymentStatus === PaymentStatusEnum.Paid) {
return;
}
const wallet = await em.findOne(UserWallet, {
user: { id: ctx.order.user.id },
restaurant: { id: ctx.order.restaurant.id },
user: { id: order.user.id },
restaurant: { id: order.restaurant.id },
});
if (!wallet) {
@@ -107,18 +114,18 @@ export class PaymentsService {
wallet.wallet -= ctx.amount;
const payment = em.create(Payment, {
const payment = await this.getOrCreateLatestPendingPayment(order.id, {
em,
amount: ctx.amount,
order: ctx.order,
method: PaymentMethodEnum.Wallet,
gateway: null,
status: PaymentStatusEnum.Paid,
paidAt: new Date(),
});
ctx.order.paymentStatus = PaymentStatusEnum.Paid;
payment.status = PaymentStatusEnum.Paid;
payment.paidAt = new Date();
order.paymentStatus = PaymentStatusEnum.Paid;
em.persist([wallet, payment, ctx.order]);
em.persist([wallet, payment, order]);
await em.flush();
});
}
@@ -129,6 +136,17 @@ export class PaymentsService {
): Promise<{ paymentUrl: string }> {
const gateway = this.gatewayManager.get(ctx.gateway!);
const payment = await this.getOrCreateLatestPendingPayment(ctx.order.id, {
amount: ctx.amount,
method: PaymentMethodEnum.Online,
gateway: ctx.gateway!,
});
// If we already requested a gateway transaction, just return the same URL (idempotent)
if (payment.transactionId) {
return { paymentUrl: gateway.getPaymentUrl(payment.transactionId) };
}
const { transactionId } = await gateway.requestPayment({
amount: ctx.amount,
orderId: ctx.order.id,
@@ -136,14 +154,9 @@ export class PaymentsService {
domain: ctx.restaurantDomain!,
});
const payment = this.em.create(Payment, {
amount: ctx.amount,
order: ctx.order,
method: PaymentMethodEnum.Online,
gateway: ctx.gateway,
transactionId,
status: PaymentStatusEnum.Pending,
});
payment.gateway = ctx.gateway;
payment.transactionId = transactionId;
payment.status = PaymentStatusEnum.Pending;
await this.em.persistAndFlush(payment);
@@ -212,4 +225,43 @@ export class PaymentsService {
);
}
private async getOrCreateLatestPendingPayment(
orderId: string,
params: {
amount: number;
method: PaymentMethodEnum;
gateway: PaymentGatewayEnum | null;
em?: EntityManager;
},
): Promise<Payment> {
const em = params.em ?? this.em;
const existing = await em.findOne(
Payment,
{ order: { id: orderId }, status: PaymentStatusEnum.Pending },
{ orderBy: { createdAt: 'DESC' } },
);
if (existing) {
if (existing.method !== params.method) {
throw new BadRequestException('Existing pending payment method does not match order payment method');
}
return existing;
}
const orderRef = em.getReference(Order, orderId);
const payment = em.create(Payment, {
amount: params.amount,
order: orderRef,
method: params.method,
gateway: params.gateway,
transactionId: null,
status: PaymentStatusEnum.Pending,
});
em.persist(payment);
await em.flush();
return payment;
}
}