refactor order craetion
This commit is contained in:
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user