diff --git a/src/modules/orders/providers/orders.service.ts b/src/modules/orders/providers/orders.service.ts index b366c63..74e004e 100644 --- a/src/modules/orders/providers/orders.service.ts +++ b/src/modules/orders/providers/orders.service.ts @@ -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 }; } /** diff --git a/src/modules/payments/controllers/payments.controller.ts b/src/modules/payments/controllers/payments.controller.ts index 0a293db..5ecf0e7 100644 --- a/src/modules/payments/controllers/payments.controller.ts +++ b/src/modules/payments/controllers/payments.controller.ts @@ -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() diff --git a/src/modules/payments/gateways/zarinpal.gateway.ts b/src/modules/payments/gateways/zarinpal.gateway.ts index 6ffd28e..de354c4 100755 --- a/src/modules/payments/gateways/zarinpal.gateway.ts +++ b/src/modules/payments/gateways/zarinpal.gateway.ts @@ -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 { // 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(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}`); } } diff --git a/src/modules/payments/interface/gateway.ts b/src/modules/payments/interface/gateway.ts index 1e20b19..98e29a8 100644 --- a/src/modules/payments/interface/gateway.ts +++ b/src/modules/payments/interface/gateway.ts @@ -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[]; } diff --git a/src/modules/payments/payments.module.ts b/src/modules/payments/payments.module.ts index 1a9cc91..899f096 100644 --- a/src/modules/payments/payments.module.ts +++ b/src/modules/payments/payments.module.ts @@ -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, diff --git a/src/modules/payments/repositories/payment.repository.ts b/src/modules/payments/repositories/payment.repository.ts new file mode 100644 index 0000000..f157dd3 --- /dev/null +++ b/src/modules/payments/repositories/payment.repository.ts @@ -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 { + constructor(readonly em: EntityManager) { + super(em, Payment); + } +} diff --git a/src/modules/payments/services/payments.service.ts b/src/modules/payments/services/payments.service.ts index 1a05628..4ff0d08 100644 --- a/src/modules/payments/services/payments.service.ts +++ b/src/modules/payments/services/payments.service.ts @@ -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 { 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 { - 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 { 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 { + 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; + } }