import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { PaymentGatewayEnum, PaymentMethodEnum, PaymentStatusEnum } from '../interface/payment'; import { Payment } from '../entities/payment.entity'; import { EntityManager } from '@mikro-orm/postgresql'; import { Order } from '../../orders/entities/order.entity'; import { Logger } from '@nestjs/common'; import { GatewayManager } from '../gateways/gateway.manager'; import { WalletTransaction } from 'src/modules/users/entities/wallet-transaction.entity'; import { OrderPaymentContext } from '../interface/payment'; import { OrderStatus } from 'src/modules/orders/interface/order.interface'; import { ChartPeriodEnum, PaymentChartDto } from '../dto/payment-chart.dto'; import { PaymentMessage, OrderMessage } from 'src/common/enums/message.enum'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { onlinePaymentSucceedEvent, paymentSucceedEvent } from '../events/payment.events'; import { WalletTransactionReason, WalletTransactionType } from 'src/modules/users/interface/wallet'; @Injectable() export class PaymentsService { private readonly logger = new Logger(PaymentsService.name); constructor( private readonly em: EntityManager, private readonly gatewayManager: GatewayManager, private readonly eventEmitter: EventEmitter2, ) { } async payOrder(orderId: string): Promise<{ paymentUrl: string | null }> { const ctx = await this.loadAndValidateOrder(orderId); // Idempotency: avoid creating/charging again for already-paid orders if (ctx.order.payments.find(p => p.status === PaymentStatusEnum.Paid)) { return { paymentUrl: null }; } switch (ctx.method) { case PaymentMethodEnum.Cash: await this.handleCashPayment(ctx); return { paymentUrl: null }; case PaymentMethodEnum.Wallet: await this.handleWalletPayment(ctx); return { paymentUrl: null }; case PaymentMethodEnum.Online: return this.handleOnlinePayment(ctx); case PaymentMethodEnum.CreditCard: await this.handleCreditCardPayment(ctx); return { paymentUrl: null }; default: throw new BadRequestException(PaymentMessage.UNSUPPORTED_PAYMENT_METHOD); } } private async loadAndValidateOrder(orderId: string): Promise { const order = await this.em.findOne( Order, { id: orderId }, { populate: ['user', 'restaurant', 'paymentMethod', 'paymentMethod.restaurant', 'paymentMethod.cards'] }, ); if (!order) { throw new NotFoundException(OrderMessage.NOT_FOUND); } if (order.total <= 0) { throw new BadRequestException(PaymentMessage.AMOUNT_MUST_BE_GREATER_THAN_ZERO); } const pm = order.paymentMethod; if (!pm) { throw new NotFoundException(PaymentMessage.PAYMENT_METHOD_NOT_FOUND); } if (pm.method === PaymentMethodEnum.Online) { if (!pm.gateway) throw new BadRequestException(PaymentMessage.GATEWAY_REQUIRED); if (!pm.merchantId) throw new BadRequestException(PaymentMessage.MERCHANT_ID_REQUIRED); if (!pm.restaurant?.domain) throw new BadRequestException(PaymentMessage.RESTAURANT_DOMAIN_REQUIRED); } return { order, amount: order.total, method: pm.method, gateway: pm.gateway ?? null, merchantId: pm.merchantId ?? null, restaurantDomain: pm.restaurant.domain ?? null, }; } private async handleCashPayment(ctx: OrderPaymentContext): Promise { await this.getOrCreateLatestPendingPayment(ctx.order.id, { amount: ctx.amount, method: PaymentMethodEnum.Cash, gateway: null, }); } private async handleCreditCardPayment(ctx: OrderPaymentContext): Promise { await this.getOrCreateLatestPendingPayment(ctx.order.id, { amount: ctx.amount, method: PaymentMethodEnum.CreditCard, gateway: null, }); } // TODO clean this code and refactor it 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(OrderMessage.NOT_FOUND); if (!order.user) throw new NotFoundException(OrderMessage.USER_NOT_FOUND); if (order.payments.find(p => p.status === PaymentStatusEnum.Paid)) { return; } const walletTransaction = await em.findOne(WalletTransaction, { user: { id: order.user.id }, restaurant: { id: order.restaurant.id }, }, { orderBy: { createdAt: 'DESC' } }); if (!walletTransaction) { throw new NotFoundException('User wallet not found'); } if (walletTransaction.balance < ctx.amount) { throw new BadRequestException('Insufficient wallet balance'); } const newBalance = walletTransaction.balance - ctx.amount; const payment = await this.getOrCreateLatestPendingPayment(order.id, { em, amount: ctx.amount, method: PaymentMethodEnum.Wallet, gateway: null, }); const newWalletTransaction = em.create(WalletTransaction, { user: order.user, restaurant: order.restaurant, amount: ctx.amount, type: WalletTransactionType.DEBIT, reason: WalletTransactionReason.ORDER_PAYMENT, balance: newBalance, }); payment.status = PaymentStatusEnum.Paid; payment.paidAt = new Date(); em.persist([ payment, order, newWalletTransaction]); await em.flush(); }); this.eventEmitter.emit( paymentSucceedEvent.name, new paymentSucceedEvent(ctx.order.id, ctx.order.restaurant.id, String(ctx.order?.orderNumber) || '', ctx.method, ctx.amount), ); } private async handleOnlinePayment(ctx: OrderPaymentContext): 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, merchantId: ctx.merchantId!, domain: ctx.restaurantDomain!, }); payment.gateway = ctx.gateway; payment.transactionId = transactionId; payment.status = PaymentStatusEnum.Pending; await this.em.persistAndFlush(payment); return { paymentUrl: gateway.getPaymentUrl(transactionId), }; } async verifyOnlinePayment(transactionId: string, orderId: string): Promise { const payment = await this.em.transactional(async em => { const payment = await em.findOne( Payment, { transactionId, order: { id: orderId } }, { populate: ['order', 'order.paymentMethod'] }, ); if (!payment) { throw new NotFoundException(PaymentMessage.PAYMENT_NOT_FOUND); } if (payment.status === PaymentStatusEnum.Paid) { return payment; } const pm = payment.order.paymentMethod; if (!pm?.merchantId || !payment.gateway) { throw new BadRequestException(PaymentMessage.INVALID_PAYMENT_CONFIGURATION); } const gateway = this.gatewayManager.get(payment.gateway); const result = await gateway.verifyPayment({ merchantId: pm.merchantId, amount: payment.amount, transactionId: payment.transactionId!, }); payment.verifyResponse = result.raw; if (!result.success) { this.failPayment(payment); return payment; } this.markPaid(payment, result.referenceId); await em.flush(); return payment; }); this.eventEmitter.emit( onlinePaymentSucceedEvent.name, new onlinePaymentSucceedEvent(payment.id, payment.order.id, payment.order.restaurant.id, String(payment.order?.orderNumber) || '', payment.amount), ); return payment; } findAllPaymentsByRestaurantId(restId: string, userId: string) { return this.em.find( Payment, { order: { restaurant: { id: restId }, user: { id: userId } } }, { populate: ['order', 'order.paymentMethod'] }, ); } async verifyCashAnCreditPayment(id: string): Promise { return this.verifyManualPayment(id, [PaymentMethodEnum.Cash, PaymentMethodEnum.CreditCard], PaymentMessage.PAYMENT_METHOD_NOT_CASH); } private async verifyManualPayment( id: string, expectedMethods: PaymentMethodEnum[], wrongMethodMessage: PaymentMessage, ): Promise { const payment = await this.em.transactional(async em => { const payment = await em.findOne(Payment, id, { populate: ['order'] }); if (!payment) { throw new NotFoundException(PaymentMessage.PAYMENT_NOT_FOUND); } if (!expectedMethods.includes(payment.method)) { throw new BadRequestException(wrongMethodMessage); } if (payment.status === PaymentStatusEnum.Paid) { throw new BadRequestException(PaymentMessage.PAYMENT_ALREADY_PAID); } payment.status = PaymentStatusEnum.Paid; payment.paidAt = new Date(); em.persist(payment); em.persist(payment.order); await em.flush(); return payment; }); return payment; } private markPaid(payment: Payment, referenceId: string) { payment.status = PaymentStatusEnum.Paid; payment.paidAt = new Date(); payment.referenceId = referenceId; } private failPayment(payment: Payment) { payment.status = PaymentStatusEnum.Failed; payment.failedAt = new Date(); payment.order.status = OrderStatus.CANCELED; } 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(PaymentMessage.EXISTING_PENDING_PAYMENT_MISMATCH); } 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; } async getChartData(dto: PaymentChartDto, restaurantId: string): Promise> { const { startDate, endDate, type = ChartPeriodEnum.Daily } = dto; const start = startDate ? new Date(startDate) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); const end = endDate ? new Date(endDate) : new Date(); const startOfDay = new Date(start); startOfDay.setHours(0, 0, 0, 0); const endOfDay = new Date(end); endOfDay.setHours(23, 59, 59, 999); // 1. Map your Enum to valid Postgres date_trunc units // 'Daily' is not valid in PG, it must be 'day' const pgPeriod = { [ChartPeriodEnum.Daily]: 'day', [ChartPeriodEnum.Weekly]: 'week', [ChartPeriodEnum.Monthly]: 'month', }[type] || 'day'; const params: any[] = [startOfDay, endOfDay]; let restaurantFilter = ''; if (restaurantId) { params.push(restaurantId); restaurantFilter = `AND o.restaurant_id = ?`; } // MikroORM uses ? placeholders for parameter binding // Ensure paid_at is not NULL and handle timestamp casting properly const query = ` SELECT TO_CHAR(DATE_TRUNC('${pgPeriod}', CAST(p.paid_at AS timestamp)), 'YYYY-MM-DD') as "date", COALESCE(SUM(CASE WHEN p.method = '${PaymentMethodEnum.Cash}' THEN p.amount ELSE 0 END), 0)::numeric as cash, COALESCE(SUM(CASE WHEN p.method = '${PaymentMethodEnum.Online}' THEN p.amount ELSE 0 END), 0)::numeric as online FROM payments p INNER JOIN orders o ON p.order_id = o.id WHERE p.status = '${PaymentStatusEnum.Paid}' AND p.paid_at IS NOT NULL AND p.paid_at >= ? AND p.paid_at <= ? ${restaurantFilter} GROUP BY DATE_TRUNC('${pgPeriod}', CAST(p.paid_at AS timestamp)) ORDER BY DATE_TRUNC('${pgPeriod}', CAST(p.paid_at AS timestamp)) ASC `; this.logger.debug(`Chart query params: startOfDay=${startOfDay.toISOString()}, endOfDay=${endOfDay.toISOString()}, restaurantId=${restaurantId}`); const result = await this.em.execute(query, params); this.logger.debug(`Chart query returned ${result.length} rows`); const dataMap = new Map(); result.forEach((row: any) => { dataMap.set(row.date, { cash: Number(row.cash), online: Number(row.online), }); }); const allDates = this.generateDateRange(startOfDay, endOfDay, type); return allDates.map(date => ({ date, cash: dataMap.get(date)?.cash ?? 0, online: dataMap.get(date)?.online ?? 0, })); } private generateDateRange(start: Date, end: Date, type: ChartPeriodEnum): string[] { const dates: string[] = []; const current = new Date(start); const endDate = new Date(end); // Normalize start date based on period type switch (type) { case ChartPeriodEnum.Weekly: // Start of week (Monday) - PostgreSQL DATE_TRUNC('week') uses ISO week (Monday as first day) const dayOfWeek = current.getDay(); const diff = current.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1); current.setDate(diff); current.setHours(0, 0, 0, 0); // Also normalize end date to include the week containing it const endDayOfWeek = endDate.getDay(); const endDiff = endDate.getDate() - endDayOfWeek + (endDayOfWeek === 0 ? -6 : 1); endDate.setDate(endDiff); endDate.setHours(23, 59, 59, 999); break; case ChartPeriodEnum.Monthly: // Start of month current.setDate(1); current.setHours(0, 0, 0, 0); // Also normalize end date to include the month containing it endDate.setDate(1); endDate.setHours(23, 59, 59, 999); break; case ChartPeriodEnum.Daily: default: current.setHours(0, 0, 0, 0); endDate.setHours(23, 59, 59, 999); } while (current <= endDate) { const dateStr = current.toISOString().split('T')[0]; dates.push(dateStr); // Increment based on period type switch (type) { case ChartPeriodEnum.Weekly: current.setDate(current.getDate() + 7); break; case ChartPeriodEnum.Monthly: current.setMonth(current.getMonth() + 1); break; case ChartPeriodEnum.Daily: default: current.setDate(current.getDate() + 1); } } return dates; } }