import { Injectable, NotFoundException, BadRequestException, Logger, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { ZibalService } from './zibal.service'; import { SmsService } from '../common/services/sms.service'; import { Prisma } from '@prisma/client'; import { AdminTransactionFilterDto } from './dto/admin-transaction-filter.dto'; export interface ClientMetadata { ipAddress?: string; userAgent?: string; } @Injectable() export class PaymentService { private readonly logger = new Logger(PaymentService.name); constructor( private readonly prisma: PrismaService, private readonly zibalService: ZibalService, private readonly smsService: SmsService, ) {} /** * 1. Initiate Online Payment for an Order via Zibal */ async initiateOrderPayment( userId: string, orderId: string, customCallbackUrl?: string, clientMeta?: ClientMetadata, ) { const order = await this.prisma.order.findFirst({ where: { id: orderId, userId }, include: { user: true }, }); if (!order) { throw new NotFoundException('سفارش مورد نظر یافت نشد'); } if (['shipped', 'delivered'].includes(order.status)) { throw new BadRequestException('این سفارش قبلاً تکمیل و ارسال شده است'); } const amountTomans = Number(order.totalAmount); const amountRials = Math.round(amountTomans * 10); if (amountRials < 1000) { throw new BadRequestException( 'مبلغ سفارش کمتر از حداقل مجاز درگاه بانکی (۱,۰۰۰ ریال) است', ); } const frontendUrl = await this.zibalService.getFrontendUrl(); const callbackUrl = customCallbackUrl || `${frontendUrl}/payment/verify?orderId=${order.id}`; const requestPayload = { amountRials, callbackUrl, orderId: order.id, mobile: order.user?.mobile || undefined, description: `پرداخت سفارش ${order.trackingNumber || ''} - پت‌شاپ کانینا`, }; // Create a pending PaymentTransaction in database const transaction = await this.prisma.paymentTransaction.create({ data: { userId, orderId: order.id, amount: new Prisma.Decimal(amountTomans), amountRials: BigInt(amountRials), gateway: 'zibal', status: 'PENDING', type: 'ORDER', description: `پرداخت آنلاین سفارش ${order.trackingNumber || order.id}`, ipAddress: clientMeta?.ipAddress, userAgent: clientMeta?.userAgent, rawRequest: requestPayload as unknown as Prisma.InputJsonValue, }, }); try { const zibalRes = await this.zibalService.requestPayment(requestPayload); if (zibalRes.result !== 100) { const errorMsg = this.zibalService.getResultMessage(zibalRes.result); await this.prisma.paymentTransaction.update({ where: { id: transaction.id }, data: { status: 'FAILED', resultCode: zibalRes.result, message: errorMsg, rawResponse: zibalRes as unknown as Prisma.InputJsonValue, }, }); throw new BadRequestException(`خطا در ایجاد تراکنش زیبال: ${errorMsg}`); } const trackId = String(zibalRes.trackId); // Update transaction with trackId await this.prisma.paymentTransaction.update({ where: { id: transaction.id }, data: { trackId, resultCode: zibalRes.result, message: zibalRes.message, rawResponse: zibalRes as unknown as Prisma.InputJsonValue, }, }); const paymentUrl = this.zibalService.getStartUrl(trackId); return { success: true, trackId, paymentUrl, orderId: order.id, amount: amountTomans, }; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); const stack = err instanceof Error ? err.stack : undefined; this.logger.error(`Error requesting payment from Zibal: ${msg}`, stack); throw new BadRequestException(msg || 'خطا در اتصال به درگاه پرداخت'); } } /** * 2. Initiate Online Wallet Top-Up via Zibal */ async initiateWalletTopup( userId: string, amountTomans: number, customCallbackUrl?: string, clientMeta?: ClientMetadata, ) { if (amountTomans < 1000) { throw new BadRequestException('حداقل مبلغ افزایش موجودی ۱,۰۰۰ تومان است'); } const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user) { throw new NotFoundException('کاربر یافت نشد'); } const amountRials = Math.round(amountTomans * 10); const frontendUrl = await this.zibalService.getFrontendUrl(); const callbackUrl = customCallbackUrl || `${frontendUrl}/payment/verify?type=wallet`; const requestPayload = { amountRials, callbackUrl, mobile: user.mobile || undefined, description: `شارژ کیف پول - پت شاپ کانینا`, }; const transaction = await this.prisma.paymentTransaction.create({ data: { userId, amount: new Prisma.Decimal(amountTomans), amountRials: BigInt(amountRials), gateway: 'zibal', status: 'PENDING', type: 'WALLET_TOPUP', description: `افزایش موجودی کیف پول کاربر ${user.mobile}`, ipAddress: clientMeta?.ipAddress, userAgent: clientMeta?.userAgent, rawRequest: requestPayload as unknown as Prisma.InputJsonValue, }, }); try { const zibalRes = await this.zibalService.requestPayment(requestPayload); if (zibalRes.result !== 100) { const errorMsg = this.zibalService.getResultMessage(zibalRes.result); await this.prisma.paymentTransaction.update({ where: { id: transaction.id }, data: { status: 'FAILED', resultCode: zibalRes.result, message: errorMsg, rawResponse: zibalRes as unknown as Prisma.InputJsonValue, }, }); throw new BadRequestException(`خطا در ایجاد تراکنش زیبال: ${errorMsg}`); } const trackId = String(zibalRes.trackId); await this.prisma.paymentTransaction.update({ where: { id: transaction.id }, data: { trackId, resultCode: zibalRes.result, message: zibalRes.message, rawResponse: zibalRes as unknown as Prisma.InputJsonValue, }, }); return { success: true, trackId, paymentUrl: this.zibalService.getStartUrl(trackId), amount: amountTomans, }; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); this.logger.error(`Error requesting wallet topup from Zibal: ${msg}`); throw new BadRequestException(msg || 'خطا در ارتباط با درگاه بانکی'); } } /** * 3. Verify & Process Callback from Zibal (With Idempotency, Amount Match & Inventory Hold Release/Consume) */ async verifyAndProcess( trackId: string, successParam?: string, statusParam?: string, orderIdParam?: string, clientMeta?: ClientMetadata, ) { this.logger.log( `Processing callback for trackId=${trackId}, success=${successParam}, status=${statusParam}, orderId=${orderIdParam}`, ); let transaction = await this.prisma.paymentTransaction.findUnique({ where: { trackId }, include: { order: { include: { user: true, orderItems: { include: { product: true } }, }, }, user: true, }, }); if (!transaction && orderIdParam) { transaction = await this.prisma.paymentTransaction.findFirst({ where: { orderId: orderIdParam }, orderBy: { createdAt: 'desc' }, include: { order: { include: { user: true, orderItems: { include: { product: true } }, }, }, user: true, }, }); } if (!transaction) { throw new NotFoundException('تراکنش پرداخت در سیستم یافت نشد'); } // --- IDEMPOTENCY CHECK --- if (transaction.status === 'VERIFIED') { this.logger.log( `Idempotency Guard: Transaction ${transaction.id} (trackId=${trackId}) is already VERIFIED. Skipping duplicate execution.`, ); return { success: true, alreadyVerified: true, status: 'VERIFIED', trackId: transaction.trackId, refNumber: transaction.refNumber, cardNumber: transaction.cardNumber, amount: Number(transaction.amount), orderId: transaction.orderId, type: transaction.type, message: 'تراکنش قبلاً با موفقیت تایید و پردازش شده است', }; } // If user cancelled or Zibal signaled failure at the gate if (successParam === '0' || statusParam === '3') { const statusMessage = this.zibalService.getStatusMessage( statusParam || 3, ); await this.prisma.paymentTransaction.update({ where: { id: transaction.id }, data: { status: 'FAILED', message: statusMessage, ipAddress: clientMeta?.ipAddress || transaction.ipAddress, userAgent: clientMeta?.userAgent || transaction.userAgent, }, }); // Release any active inventory reservation for this order if (transaction.orderId) { await this.prisma.inventoryReservation.updateMany({ where: { orderId: transaction.orderId, status: 'ACTIVE' }, data: { status: 'RELEASED' }, }); } return { success: false, status: 'FAILED', trackId, message: statusMessage, orderId: transaction.orderId, }; } // Call Zibal verify endpoint try { const verifyRes = await this.zibalService.verifyPayment(trackId); const isSuccess = verifyRes.result === 100 || verifyRes.result === 201; if (!isSuccess) { const errorMsg = this.zibalService.getResultMessage(verifyRes.result); await this.prisma.paymentTransaction.update({ where: { id: transaction.id }, data: { status: 'FAILED', resultCode: verifyRes.result, message: errorMsg, cardNumber: verifyRes.cardNumber, rawResponse: verifyRes as unknown as Prisma.InputJsonValue, }, }); // Release inventory reservations on failure if (transaction.orderId) { await this.prisma.inventoryReservation.updateMany({ where: { orderId: transaction.orderId, status: 'ACTIVE' }, data: { status: 'RELEASED' }, }); } return { success: false, status: 'FAILED', trackId, resultCode: verifyRes.result, message: errorMsg, orderId: transaction.orderId, }; } // --- AMOUNT MATCH VERIFICATION --- const expectedAmountRials = Number( transaction.amountRials || Math.round(Number(transaction.amount) * 10), ); if ( verifyRes.amount && Number(verifyRes.amount) > 0 && Number(verifyRes.amount) !== expectedAmountRials ) { this.logger.error( `Security Alert: Amount mismatch on trackId=${trackId}! Zibal reported ${verifyRes.amount} Rials, but expected ${expectedAmountRials} Rials in DB.`, ); await this.prisma.paymentTransaction.update({ where: { id: transaction.id }, data: { status: 'FAILED', resultCode: verifyRes.result, message: `مغایرت مالی: مبلغ تایید شده زیبال (${verifyRes.amount} ریال) با مبلغ سیستم (${expectedAmountRials} ریال) مطابقت ندارد.`, rawResponse: verifyRes as unknown as Prisma.InputJsonValue, }, }); return { success: false, status: 'FAILED', trackId, message: 'خطای امنیتی: مغایرت در مبلغ پرداخت شده با سفارش.', orderId: transaction.orderId, }; } // SUCCESSFUL PAYMENT! const refNumber = verifyRes.refNumber ? String(verifyRes.refNumber) : undefined; const cardNumber = verifyRes.cardNumber || undefined; const paidDate = verifyRes.paidAt ? new Date(verifyRes.paidAt) : new Date(); // Execute atomic status update with concurrency guard await this.prisma.$transaction(async (tx) => { // Optimistic lock: only update if still PENDING const updatedCount = await tx.paymentTransaction.updateMany({ where: { id: transaction.id, status: 'PENDING' }, data: { status: 'VERIFIED', resultCode: verifyRes.result, refNumber, cardNumber, paidAt: paidDate, message: 'پرداخت با موفقیت انجام و تایید شد', rawResponse: verifyRes as unknown as Prisma.InputJsonValue, ipAddress: clientMeta?.ipAddress || transaction.ipAddress, userAgent: clientMeta?.userAgent || transaction.userAgent, }, }); if (updatedCount.count === 0) { this.logger.warn( `Concurrent execution detected for transaction ${transaction.id}. Handled safely.`, ); return; } // 2. If it's an Order payment -> Mark processing & Consume Inventory Reservation if (transaction.type === 'ORDER' && transaction.orderId) { await tx.order.update({ where: { id: transaction.orderId }, data: { status: 'processing', paymentMethod: 'online', }, }); // Mark inventory reservations as CONSUMED await tx.inventoryReservation.updateMany({ where: { orderId: transaction.orderId, status: 'ACTIVE' }, data: { status: 'CONSUMED' }, }); // If charity donation was made, update user's total charity if ( transaction.order && Number(transaction.order.charityDonation) > 0 ) { await tx.user.update({ where: { id: transaction.userId }, data: { charityDonationTotal: { increment: Number(transaction.order.charityDonation), }, }, }); } } // 3. If it's a Wallet Top-up if (transaction.type === 'WALLET_TOPUP') { await tx.user.update({ where: { id: transaction.userId }, data: { walletBalance: { increment: Number(transaction.amount), }, }, }); await tx.walletTransaction.create({ data: { userId: transaction.userId, amount: transaction.amount, type: 'deposit', status: 'completed', transactionReference: refNumber || `ZBL-${trackId}`, description: `افزایش اعتبار آنلاین از طریق درگاه زیبال (شناسه: ${trackId})`, }, }); } }); // Send SMS notification if order payment if (transaction.type === 'ORDER' && transaction.order?.user?.mobile) { const mobile = transaction.order.user.mobile; const trackingNum = transaction.order.trackingNumber || transaction.order.id; const formattedAmount = Number(transaction.amount).toLocaleString( 'fa-IR', ); this.smsService .sendOrderConfirmation(mobile, trackingNum, formattedAmount) .catch((err) => { this.logger.warn(`Could not send SMS confirmation: ${err}`); }); } return { success: true, status: 'VERIFIED', trackId, refNumber, cardNumber, amount: Number(transaction.amount), orderId: transaction.orderId, type: transaction.type, message: 'پرداخت با موفقیت انجام شد', }; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); const stack = err instanceof Error ? err.stack : undefined; this.logger.error(`Error during verify: ${msg}`, stack); return { success: false, status: 'ERROR', trackId, message: msg || 'خطا در فرایند تایید تراکنش', orderId: transaction.orderId, }; } } /** * 4. Auto-Reconciliation / Cron Job for Pending Transactions */ async reconcilePendingTransactions() { this.logger.log('Starting pending transactions reconciliation job...'); const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); const pendingTransactions = await this.prisma.paymentTransaction.findMany({ where: { status: 'PENDING', trackId: { not: null }, createdAt: { gte: oneDayAgo, lte: fiveMinutesAgo, }, }, include: { order: true, user: true }, take: 50, }); const results = { total: pendingTransactions.length, verified: 0, failed: 0, remainedPending: 0, errors: 0, }; for (const tx of pendingTransactions) { if (!tx.trackId) continue; try { const inquiry = await this.zibalService.inquiryPayment(tx.trackId); this.logger.log( `Inquiry for trackId=${tx.trackId}: status=${inquiry.status}, result=${inquiry.result}`, ); if (inquiry.status === 1 || inquiry.status === 2 || inquiry.result === 100) { await this.verifyAndProcess(tx.trackId, '1', String(inquiry.status), tx.orderId || undefined); results.verified++; } else if (inquiry.status === 3 || inquiry.status === -2 || inquiry.result === 202) { const msg = this.zibalService.getStatusMessage(inquiry.status || 3); await this.prisma.paymentTransaction.update({ where: { id: tx.id }, data: { status: 'FAILED', resultCode: inquiry.result, message: msg, rawResponse: inquiry as unknown as Prisma.InputJsonValue, }, }); if (tx.orderId) { await this.prisma.inventoryReservation.updateMany({ where: { orderId: tx.orderId, status: 'ACTIVE' }, data: { status: 'RELEASED' }, }); } results.failed++; } else { results.remainedPending++; } } catch (err: unknown) { results.errors++; const msg = err instanceof Error ? err.message : String(err); this.logger.warn(`Failed inquiry for trackId=${tx.trackId}: ${msg}`); } } // Also release all expired reservations const now = new Date(); await this.prisma.inventoryReservation.updateMany({ where: { status: 'ACTIVE', expiresAt: { lte: now } }, data: { status: 'RELEASED' }, }); this.logger.log(`Reconciliation finished: ${JSON.stringify(results)}`); return results; } /** * 5. Get Payment Transaction Details by TrackID */ async getTransaction(trackId: string) { const transaction = await this.prisma.paymentTransaction.findUnique({ where: { trackId }, include: { order: { include: { orderItems: { include: { product: true }, }, }, }, user: true, }, }); if (!transaction) { throw new NotFoundException('تراکنش یافت نشد'); } return { id: transaction.id, trackId: transaction.trackId, refNumber: transaction.refNumber, amount: Number(transaction.amount), status: transaction.status, message: transaction.message, gateway: transaction.gateway, type: transaction.type, cardNumber: transaction.cardNumber, ipAddress: transaction.ipAddress, userAgent: transaction.userAgent, rawRequest: transaction.rawRequest, rawResponse: transaction.rawResponse, paidAt: transaction.paidAt, createdAt: transaction.createdAt, order: transaction.order, user: transaction.user ? { id: transaction.user.id, firstName: transaction.user.firstName, lastName: transaction.user.lastName, mobile: transaction.user.mobile, } : null, }; } /** * 6. Admin: Get List of Transactions with Advanced Search & Filter */ async getAdminTransactions(filters: AdminTransactionFilterDto) { const { page = 1, limit = 15, search, status, gateway, type, startDate, endDate, minAmount, maxAmount, sortBy = 'createdAt', sortOrder = 'desc', } = filters; const skip = (page - 1) * limit; const where: Prisma.PaymentTransactionWhereInput = {}; if (status) { where.status = status; } if (gateway) { where.gateway = gateway; } if (type) { where.type = type; } if (startDate || endDate) { where.createdAt = {}; if (startDate) where.createdAt.gte = new Date(startDate); if (endDate) where.createdAt.lte = new Date(endDate); } if (minAmount !== undefined || maxAmount !== undefined) { where.amount = {}; if (minAmount !== undefined) where.amount.gte = new Prisma.Decimal(minAmount); if (maxAmount !== undefined) where.amount.lte = new Prisma.Decimal(maxAmount); } if (search && search.trim() !== '') { const s = search.trim(); where.OR = [ { trackId: { contains: s, mode: 'insensitive' } }, { refNumber: { contains: s, mode: 'insensitive' } }, { cardNumber: { contains: s, mode: 'insensitive' } }, { description: { contains: s, mode: 'insensitive' } }, { user: { OR: [ { firstName: { contains: s, mode: 'insensitive' } }, { lastName: { contains: s, mode: 'insensitive' } }, { mobile: { contains: s, mode: 'insensitive' } }, ], }, }, { order: { trackingNumber: { contains: s, mode: 'insensitive' }, }, }, ]; } const [data, total] = await Promise.all([ this.prisma.paymentTransaction.findMany({ where, skip, take: limit, orderBy: { [sortBy]: sortOrder }, include: { user: { select: { id: true, firstName: true, lastName: true, mobile: true, email: true, }, }, order: { select: { id: true, trackingNumber: true, totalAmount: true, status: true, paymentMethod: true, }, }, }, }), this.prisma.paymentTransaction.count({ where }), ]); return { data, meta: { total, page, lastPage: Math.ceil(total / limit), limit, }, }; } /** * 7. Admin: Get Transaction Statistics */ async getAdminTransactionStats() { const [ totalCount, verifiedCount, pendingCount, failedCount, verifiedSum, todayVerified, ] = await Promise.all([ this.prisma.paymentTransaction.count(), this.prisma.paymentTransaction.count({ where: { status: 'VERIFIED' } }), this.prisma.paymentTransaction.count({ where: { status: 'PENDING' } }), this.prisma.paymentTransaction.count({ where: { status: 'FAILED' } }), this.prisma.paymentTransaction.aggregate({ _sum: { amount: true }, where: { status: 'VERIFIED' }, }), this.prisma.paymentTransaction.aggregate({ _sum: { amount: true }, where: { status: 'VERIFIED', createdAt: { gte: new Date(new Date().setHours(0, 0, 0, 0)), }, }, }), ]); const totalVolume = Number(verifiedSum._sum.amount || 0); const todayVolume = Number(todayVerified._sum.amount || 0); const successRate = totalCount > 0 ? Math.round((verifiedCount / totalCount) * 100) : 0; return { totalCount, verifiedCount, pendingCount, failedCount, totalVolume, todayVolume, successRate, }; } /** * 8. Admin: Perform Live Instant Inquiry on Zibal for a Transaction */ async adminLiveInquiry(transactionId: string) { const transaction = await this.prisma.paymentTransaction.findUnique({ where: { id: transactionId }, include: { order: true, user: true }, }); if (!transaction) { throw new NotFoundException('تراکنش یافت نشد'); } if (!transaction.trackId) { throw new BadRequestException('این تراکنش فاقد TrackId زیبال است'); } const inquiryRes = await this.zibalService.inquiryPayment(transaction.trackId); const statusMsg = this.zibalService.getStatusMessage(inquiryRes.status ?? 3); const resultMsg = this.zibalService.getResultMessage(inquiryRes.result); if ( transaction.status === 'PENDING' && (inquiryRes.status === 1 || inquiryRes.status === 2 || inquiryRes.result === 100) ) { await this.verifyAndProcess( transaction.trackId, '1', String(inquiryRes.status), transaction.orderId || undefined, ); } return { transactionId: transaction.id, trackId: transaction.trackId, currentDbStatus: transaction.status, gatewayResponse: inquiryRes, statusMessage: statusMsg, resultMessage: resultMsg, inquiredAt: new Date(), }; } /** * 9. Gateway Health Check for Admin */ async checkGatewayHealth() { return this.zibalService.checkHealth(); } }