diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 2be4180..99b15bd 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -158,9 +158,10 @@ model Product { category Category @relation(fields: [categoryId], references: [id]) ingredientList ProductIngredient[] symptoms ProductSymptom[] - reminders Reminder[] - orderItems OrderItem[] - advisorRules SmartAdvisorRule[] + reminders Reminder[] + orderItems OrderItem[] + advisorRules SmartAdvisorRule[] + inventoryReservations InventoryReservation[] @@index([categorySlug]) @@index([suitableFor]) @@ -326,6 +327,7 @@ model Order { coupon Coupon? @relation(fields: [couponId], references: [id]) orderItems OrderItem[] paymentTransactions PaymentTransaction[] + inventoryReservations InventoryReservation[] @@map("orders") } @@ -346,6 +348,10 @@ model PaymentTransaction { message String? @db.Text description String? @db.Text type String @default("ORDER") @db.VarChar(30) // ORDER, WALLET_TOPUP + ipAddress String? @map("ip_address") @db.VarChar(50) + userAgent String? @map("user_agent") @db.Text + rawRequest Json? @map("raw_request") + rawResponse Json? @map("raw_response") paidAt DateTime? @map("paid_at") @db.Timestamptz() createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz() @@ -359,6 +365,26 @@ model PaymentTransaction { @@map("payment_transactions") } +model InventoryReservation { + id String @id @default(uuid()) @db.Uuid + orderId String @map("order_id") @db.Uuid + productId String @map("product_id") @db.Uuid + quantity Int @default(1) + expiresAt DateTime @map("expires_at") @db.Timestamptz() + status String @default("ACTIVE") @db.VarChar(20) // ACTIVE, CONSUMED, RELEASED + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz() + + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + + @@index([orderId]) + @@index([productId]) + @@index([status]) + @@index([expiresAt]) + @@map("inventory_reservations") +} + model OrderItem { id String @id @default(uuid()) @db.Uuid orderId String @map("order_id") @db.Uuid diff --git a/backend/src/orders/orders.service.ts b/backend/src/orders/orders.service.ts index 3c242d9..dffe980 100644 --- a/backend/src/orders/orders.service.ts +++ b/backend/src/orders/orders.service.ts @@ -244,6 +244,7 @@ export class OrdersService { const isOnline = createOrderDto.paymentMethod === 'online'; const initialStatus = isOnline ? 'pending_payment' : 'processing'; + const reservationExpiry = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes hold const createdOrder = await this.prisma.order.create({ data: { @@ -260,11 +261,20 @@ export class OrdersService { orderItems: { create: orderItemsData, }, + inventoryReservations: { + create: orderItemsData.map((item) => ({ + productId: item.productId, + quantity: item.quantity, + expiresAt: reservationExpiry, + status: isOnline ? 'ACTIVE' : 'CONSUMED', + })), + }, }, include: { orderItems: { include: { product: true }, }, + inventoryReservations: true, }, }); @@ -391,4 +401,25 @@ export class OrdersService { } } } + + /** + * Release inventory reservations that expired without successful payment + */ + async releaseExpiredInventoryReservations() { + const now = new Date(); + const expiredReservations = await this.prisma.inventoryReservation.updateMany({ + where: { + status: 'ACTIVE', + expiresAt: { lte: now }, + }, + data: { + status: 'RELEASED', + }, + }); + + return { + releasedCount: expiredReservations.count, + executedAt: now, + }; + } } diff --git a/backend/src/payment/interfaces/payment-gateway.interface.ts b/backend/src/payment/interfaces/payment-gateway.interface.ts new file mode 100644 index 0000000..0249650 --- /dev/null +++ b/backend/src/payment/interfaces/payment-gateway.interface.ts @@ -0,0 +1,55 @@ +export interface PaymentRequestOptions { + amountRials: number; + callbackUrl: string; + orderId?: string; + mobile?: string; + description?: string; +} + +export interface PaymentRequestResult { + result: number; + trackId: string | number; + message?: string; + paymentUrl?: string; +} + +export interface PaymentVerifyResult { + result: number; + refNumber?: string; + cardNumber?: string; + amount?: number; + paidAt?: string; + message?: string; +} + +export interface PaymentInquiryResult { + result: number; + status?: number; + amount?: number; + refNumber?: string; + cardNumber?: string; + paidAt?: string; + message?: string; +} + +export interface GatewayHealthResult { + gatewayName: string; + status: 'ONLINE' | 'DEGRADED' | 'OFFLINE'; + latencyMs: number; + merchantConfigured: boolean; + activeMerchant: string; + message: string; + checkedAt: Date; +} + +/** + * Standard Payment Gateway Interface (Strategy / Gateway Adapter Pattern) + */ +export interface IPaymentGateway { + getGatewayName(): string; + requestPayment(options: PaymentRequestOptions): Promise; + verifyPayment(trackId: string | number): Promise; + inquiryPayment(trackId: string | number): Promise; + getStartUrl(trackId: string | number): string; + checkHealth(): Promise; +} diff --git a/backend/src/payment/payment.controller.ts b/backend/src/payment/payment.controller.ts index 81d0fbb..08072e3 100644 --- a/backend/src/payment/payment.controller.ts +++ b/backend/src/payment/payment.controller.ts @@ -9,8 +9,10 @@ import { Req, Res, HttpStatus, + Headers, + Ip, } from '@nestjs/common'; -import type { Response } from 'express'; +import type { Request, Response } from 'express'; import { PaymentService } from './payment.service'; import { ZibalService } from './zibal.service'; import { @@ -44,15 +46,6 @@ export class PaymentController { @ApiOperation({ summary: 'شروع فرایند پرداخت آنلاین سفارش با درگاه زیبال' }) @ApiOkResponse({ description: 'لینک هدایت به درگاه پرداخت زیبال با موفقیت تولید شد', - schema: { - example: { - success: true, - trackId: '15966442233311', - paymentUrl: 'https://gateway.zibal.ir/start/15966442233311', - orderId: 'e1d2c3b4-1234-5678-abcd-ef1234567890', - amount: 350000, - }, - }, }) @ApiBadRequestResponse({ description: 'خطا در ایجاد تراکنش یا نامعتبر بودن سفارش', @@ -60,11 +53,14 @@ export class PaymentController { async initiateOrderPayment( @Req() req: { user: { id: string } }, @Body() body: InitiatePaymentDto, + @Ip() ip: string, + @Headers('user-agent') userAgent?: string, ) { return this.paymentService.initiateOrderPayment( req.user.id, body.orderId, body.customCallbackUrl, + { ipAddress: ip, userAgent }, ); } @@ -78,11 +74,14 @@ export class PaymentController { async initiateWalletTopup( @Req() req: { user: { id: string } }, @Body() body: InitiateWalletTopupDto, + @Ip() ip: string, + @Headers('user-agent') userAgent?: string, ) { return this.paymentService.initiateWalletTopup( req.user.id, Number(body.amount), body.customCallbackUrl, + { ipAddress: ip, userAgent }, ); } @@ -93,6 +92,8 @@ export class PaymentController { async handleZibalCallback( @Query() query: ZibalCallbackQueryDto, @Res() res: Response, + @Ip() ip: string, + @Headers('user-agent') userAgent?: string, ) { const trackId = query.trackId || ''; const success = query.success; @@ -113,6 +114,7 @@ export class PaymentController { success, status, orderId, + { ipAddress: ip, userAgent }, ); const params = new URLSearchParams({ @@ -141,6 +143,8 @@ export class PaymentController { async handleZibalLazyCallback( @Body() body: ZibalCallbackQueryDto, @Res() res: Response, + @Ip() ip: string, + @Headers('user-agent') userAgent?: string, ) { const trackId = body.trackId || ''; const success = body.success; @@ -158,6 +162,7 @@ export class PaymentController { success, status, orderId, + { ipAddress: ip, userAgent }, ); return res.status(HttpStatus.OK).json(result); @@ -169,7 +174,7 @@ export class PaymentController { return this.paymentService.getTransaction(trackId); } - // --- ADMIN ENDPOINTS FOR TRANSACTION LOGS & DIAGNOSTICS --- + // --- ADMIN ENDPOINTS FOR TRANSACTION LOGS, DIAGNOSTICS & HEALTH CHECK --- @UseGuards(JwtAuthGuard, RolesGuard) @Roles('Admin') @@ -206,4 +211,13 @@ export class PaymentController { async liveInquiry(@Param('id') id: string) { return this.paymentService.adminLiveInquiry(id); } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('Admin') + @ApiBearerAuth() + @Get('admin/health') + @ApiOperation({ summary: 'بررسی وضعیت آنلاین بودن و سلامت درگاه پرداخت (Health Check & Latency)' }) + async getHealth() { + return this.paymentService.checkGatewayHealth(); + } } diff --git a/backend/src/payment/payment.service.ts b/backend/src/payment/payment.service.ts index a834404..da887e2 100644 --- a/backend/src/payment/payment.service.ts +++ b/backend/src/payment/payment.service.ts @@ -10,6 +10,11 @@ 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); @@ -27,6 +32,7 @@ export class PaymentService { userId: string, orderId: string, customCallbackUrl?: string, + clientMeta?: ClientMetadata, ) { const order = await this.prisma.order.findFirst({ where: { id: orderId, userId }, @@ -54,6 +60,14 @@ export class PaymentService { 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: { @@ -65,17 +79,14 @@ export class PaymentService { 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({ - amountRials, - callbackUrl, - orderId: order.id, - mobile: order.user?.mobile || undefined, - description: `پرداخت سفارش ${order.trackingNumber || ''} - پت‌شاپ کانینا`, - }); + const zibalRes = await this.zibalService.requestPayment(requestPayload); if (zibalRes.result !== 100) { const errorMsg = this.zibalService.getResultMessage(zibalRes.result); @@ -85,6 +96,7 @@ export class PaymentService { status: 'FAILED', resultCode: zibalRes.result, message: errorMsg, + rawResponse: zibalRes as unknown as Prisma.InputJsonValue, }, }); throw new BadRequestException(`خطا در ایجاد تراکنش زیبال: ${errorMsg}`); @@ -99,6 +111,7 @@ export class PaymentService { trackId, resultCode: zibalRes.result, message: zibalRes.message, + rawResponse: zibalRes as unknown as Prisma.InputJsonValue, }, }); @@ -126,6 +139,7 @@ export class PaymentService { userId: string, amountTomans: number, customCallbackUrl?: string, + clientMeta?: ClientMetadata, ) { if (amountTomans < 1000) { throw new BadRequestException('حداقل مبلغ افزایش موجودی ۱,۰۰۰ تومان است'); @@ -141,6 +155,13 @@ export class PaymentService { 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, @@ -150,16 +171,14 @@ export class PaymentService { 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({ - amountRials, - callbackUrl, - mobile: user.mobile || undefined, - description: `شارژ کیف پول - پت شاپ کانینا`, - }); + const zibalRes = await this.zibalService.requestPayment(requestPayload); if (zibalRes.result !== 100) { const errorMsg = this.zibalService.getResultMessage(zibalRes.result); @@ -169,6 +188,7 @@ export class PaymentService { status: 'FAILED', resultCode: zibalRes.result, message: errorMsg, + rawResponse: zibalRes as unknown as Prisma.InputJsonValue, }, }); throw new BadRequestException(`خطا در ایجاد تراکنش زیبال: ${errorMsg}`); @@ -182,6 +202,7 @@ export class PaymentService { trackId, resultCode: zibalRes.result, message: zibalRes.message, + rawResponse: zibalRes as unknown as Prisma.InputJsonValue, }, }); @@ -199,13 +220,14 @@ export class PaymentService { } /** - * 3. Verify & Process Callback from Zibal (With Idempotency & Amount Match Check) + * 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}`, @@ -245,8 +267,6 @@ export class PaymentService { } // --- IDEMPOTENCY CHECK --- - // If transaction is already marked as VERIFIED, return stored confirmation immediately - // without executing any duplicate side-effects (wallet increment, charity, status change, SMS). if (transaction.status === 'VERIFIED') { this.logger.log( `Idempotency Guard: Transaction ${transaction.id} (trackId=${trackId}) is already VERIFIED. Skipping duplicate execution.`, @@ -275,9 +295,19 @@ export class PaymentService { 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', @@ -301,9 +331,18 @@ export class PaymentService { 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', @@ -335,6 +374,7 @@ export class PaymentService { status: 'FAILED', resultCode: verifyRes.result, message: `مغایرت مالی: مبلغ تایید شده زیبال (${verifyRes.amount} ریال) با مبلغ سیستم (${expectedAmountRials} ریال) مطابقت ندارد.`, + rawResponse: verifyRes as unknown as Prisma.InputJsonValue, }, }); @@ -358,7 +398,7 @@ export class PaymentService { // Execute atomic status update with concurrency guard await this.prisma.$transaction(async (tx) => { - // Optimistic lock: only update if still PENDING (prevents race condition) + // Optimistic lock: only update if still PENDING const updatedCount = await tx.paymentTransaction.updateMany({ where: { id: transaction.id, status: 'PENDING' }, data: { @@ -368,10 +408,12 @@ export class PaymentService { cardNumber, paidAt: paidDate, message: 'پرداخت با موفقیت انجام و تایید شد', + rawResponse: verifyRes as unknown as Prisma.InputJsonValue, + ipAddress: clientMeta?.ipAddress || transaction.ipAddress, + userAgent: clientMeta?.userAgent || transaction.userAgent, }, }); - // If another concurrent thread verified it first, exit safely if (updatedCount.count === 0) { this.logger.warn( `Concurrent execution detected for transaction ${transaction.id}. Handled safely.`, @@ -379,7 +421,7 @@ export class PaymentService { return; } - // 2. If it's an Order payment + // 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 }, @@ -389,6 +431,12 @@ export class PaymentService { }, }); + // 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 && @@ -471,12 +519,11 @@ export class PaymentService { } /** - * 4. Auto-Reconciliation / Cron Job for Pending Transactions (استعلام تراکنش‌های بلاتکلیف) + * 4. Auto-Reconciliation / Cron Job for Pending Transactions */ async reconcilePendingTransactions() { this.logger.log('Starting pending transactions reconciliation job...'); - // Find transactions that are PENDING and created between 5 minutes and 24 hours ago const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); @@ -510,12 +557,10 @@ export class PaymentService { `Inquiry for trackId=${tx.trackId}: status=${inquiry.status}, result=${inquiry.result}`, ); - // Status 1 (paid & verified) or 2 (paid, unverified) 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) { - // Cancelled or failed const msg = this.zibalService.getStatusMessage(inquiry.status || 3); await this.prisma.paymentTransaction.update({ where: { id: tx.id }, @@ -523,8 +568,16 @@ export class PaymentService { 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++; @@ -536,6 +589,13 @@ export class PaymentService { } } + // 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; } @@ -572,6 +632,10 @@ export class PaymentService { 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, @@ -760,10 +824,9 @@ export class PaymentService { } const inquiryRes = await this.zibalService.inquiryPayment(transaction.trackId); - const statusMsg = this.zibalService.getStatusMessage(inquiryRes.status); + const statusMsg = this.zibalService.getStatusMessage(inquiryRes.status ?? 3); const resultMsg = this.zibalService.getResultMessage(inquiryRes.result); - // If transaction was PENDING and inquiry confirms payment, sync automatically if ( transaction.status === 'PENDING' && (inquiryRes.status === 1 || inquiryRes.status === 2 || inquiryRes.result === 100) @@ -786,4 +849,11 @@ export class PaymentService { inquiredAt: new Date(), }; } + + /** + * 9. Gateway Health Check for Admin + */ + async checkGatewayHealth() { + return this.zibalService.checkHealth(); + } } diff --git a/backend/src/payment/zibal.service.ts b/backend/src/payment/zibal.service.ts index 5dc32e0..994cdd1 100644 --- a/backend/src/payment/zibal.service.ts +++ b/backend/src/payment/zibal.service.ts @@ -1,5 +1,13 @@ import { Injectable, Logger } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { + IPaymentGateway, + PaymentRequestOptions, + PaymentRequestResult, + PaymentVerifyResult, + PaymentInquiryResult, + GatewayHealthResult, +} from './interfaces/payment-gateway.interface'; export interface ZibalRequestResponse { trackId: number; @@ -32,12 +40,51 @@ export interface ZibalInquiryResponse { } @Injectable() -export class ZibalService { +export class ZibalService implements IPaymentGateway { private readonly logger = new Logger(ZibalService.name); private readonly baseUrl = 'https://gateway.zibal.ir'; constructor(private readonly prisma: PrismaService) {} + getGatewayName(): string { + return 'zibal'; + } + + /** + * Helper: Execute an HTTP operation with Exponential Backoff Retry (1s, 2s, 4s) + */ + private async callWithRetry( + operation: () => Promise, + operationName: string, + maxRetries = 3, + delays = [1000, 2000, 4000], + ): Promise { + let lastError: unknown; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + return await operation(); + } catch (err: unknown) { + lastError = err; + const msg = err instanceof Error ? err.message : String(err); + this.logger.warn( + `[Zibal Retry] Attempt ${attempt}/${maxRetries} failed for '${operationName}': ${msg}`, + ); + + if (attempt < maxRetries) { + const delayMs = delays[attempt - 1] || 1000; + this.logger.log(`Waiting ${delayMs}ms before retry attempt ${attempt + 1}...`); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + } + + this.logger.error( + `[Zibal Retry] All ${maxRetries} attempts failed for '${operationName}'.`, + ); + throw lastError; + } + /** * Retrieves active merchant code from settings (DB) or fallback to env / 'zibal' sandbox. */ @@ -76,15 +123,10 @@ export class ZibalService { } /** - * 1. Request Payment (درخواست پرداخت) + * 1. Request Payment (درخواست پرداخت با قابلیت Retry) * Amount must be in Rials. */ - async requestPayment(params: { - amountRials: number; - callbackUrl: string; - description?: string; - orderId?: string; - mobile?: string; + async requestPayment(params: PaymentRequestOptions & { nationalCode?: string; allowedCards?: string[]; }): Promise { @@ -101,42 +143,46 @@ export class ZibalService { allowedCards: params.allowedCards, }; - this.logger.log( - `Sending payment request to Zibal for orderId=${params.orderId}, amount=${params.amountRials} Rials, merchant=${merchant}`, - ); - - const response = await fetch(`${this.baseUrl}/v1/request`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - const errorText = await response.text(); - this.logger.error( - `Zibal request HTTP error ${response.status}: ${errorText}`, + return this.callWithRetry(async () => { + this.logger.log( + `Sending payment request to Zibal for orderId=${params.orderId}, amount=${params.amountRials} Rials, merchant=${merchant}`, ); - throw new Error( - `خطا در اتصال به درگاه زیبال: کد وضعیت ${response.status}`, - ); - } - const data = (await response.json()) as ZibalRequestResponse; - this.logger.log(`Zibal request response: ${JSON.stringify(data)}`); - return data; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + try { + const response = await fetch(`${this.baseUrl}/v1/request`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Zibal request HTTP ${response.status}: ${errorText}`); + } + + const data = (await response.json()) as ZibalRequestResponse; + return data; + } finally { + clearTimeout(timeoutId); + } + }, `requestPayment(orderId=${params.orderId})`); } /** - * 2. Generate Start Payment URL (آدرس هدایت به درگاه) + * 2. Start URL for user redirect */ getStartUrl(trackId: string | number): string { return `${this.baseUrl}/start/${trackId}`; } /** - * 3. Verify Payment (تایید تراکنش) + * 3. Verify Payment (تایید تراکنش با قابلیت Retry) */ async verifyPayment(trackId: string | number): Promise { const merchant = await this.getMerchant(); @@ -146,39 +192,39 @@ export class ZibalService { trackId: String(trackId), }; - this.logger.log( - `Verifying payment on Zibal for trackId=${trackId}, merchant=${merchant}`, - ); + return this.callWithRetry(async () => { + this.logger.log(`Verifying payment for trackId=${trackId}, merchant=${merchant}`); - const response = await fetch(`${this.baseUrl}/v1/verify`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); - if (!response.ok) { - const errorText = await response.text(); - this.logger.error( - `Zibal verify HTTP error ${response.status}: ${errorText}`, - ); - throw new Error( - `خطا در تایید تراکنش درگاه زیبال: کد وضعیت ${response.status}`, - ); - } + try { + const response = await fetch(`${this.baseUrl}/v1/verify`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + signal: controller.signal, + }); - const data = (await response.json()) as ZibalVerifyResponse; - this.logger.log(`Zibal verify response: ${JSON.stringify(data)}`); - return data; + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Zibal verify HTTP ${response.status}: ${errorText}`); + } + + const data = (await response.json()) as ZibalVerifyResponse; + return data; + } finally { + clearTimeout(timeoutId); + } + }, `verifyPayment(trackId=${trackId})`); } /** - * 4. Inquiry Payment (استعلام وضعیت تراکنش) + * 4. Inquiry Payment (استعلام تراکنش با قابلیت Retry) */ - async inquiryPayment( - trackId: string | number, - ): Promise { + async inquiryPayment(trackId: string | number): Promise { const merchant = await this.getMerchant(); const payload = { @@ -186,78 +232,137 @@ export class ZibalService { trackId: String(trackId), }; - this.logger.log(`Inquiring payment on Zibal for trackId=${trackId}`); + return this.callWithRetry(async () => { + this.logger.log(`Inquiring payment for trackId=${trackId}`); - const response = await fetch(`${this.baseUrl}/v1/inquiry`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); - if (!response.ok) { - const errorText = await response.text(); - this.logger.error( - `Zibal inquiry HTTP error ${response.status}: ${errorText}`, - ); - throw new Error( - `خطا در استعلام تراکنش زیبال: کد وضعیت ${response.status}`, - ); + try { + const response = await fetch(`${this.baseUrl}/v1/inquiry`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Zibal inquiry HTTP ${response.status}: ${errorText}`); + } + + const data = (await response.json()) as ZibalInquiryResponse; + return data; + } finally { + clearTimeout(timeoutId); + } + }, `inquiryPayment(trackId=${trackId})`); + } + + /** + * 5. Health Check & Latency Monitor for Admin + */ + async checkHealth(): Promise { + const merchant = await this.getMerchant(); + const startTime = Date.now(); + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + const res = await fetch(`${this.baseUrl}/v1/inquiry`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ merchant, trackId: '1' }), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + const latencyMs = Date.now() - startTime; + + if (res.ok || res.status === 400 || res.status === 200) { + return { + gatewayName: 'Zibal IPG', + status: latencyMs < 1500 ? 'ONLINE' : 'DEGRADED', + latencyMs, + merchantConfigured: merchant !== 'zibal', + activeMerchant: merchant === 'zibal' ? 'zibal (تستی/سندباکس)' : `${merchant.slice(0, 4)}****`, + message: 'اتصال به درگاه زیبال با موفقیت برقرار است', + checkedAt: new Date(), + }; + } + + return { + gatewayName: 'Zibal IPG', + status: 'DEGRADED', + latencyMs, + merchantConfigured: merchant !== 'zibal', + activeMerchant: merchant, + message: `پاسخ غیرعادی سرور زیبال (${res.status})`, + checkedAt: new Date(), + }; + } catch (err: unknown) { + const latencyMs = Date.now() - startTime; + const msg = err instanceof Error ? err.message : String(err); + return { + gatewayName: 'Zibal IPG', + status: 'OFFLINE', + latencyMs, + merchantConfigured: merchant !== 'zibal', + activeMerchant: merchant, + message: `عدم دسترسی به درگاه: ${msg}`, + checkedAt: new Date(), + }; } - - const data = (await response.json()) as ZibalInquiryResponse; - return data; } /** - * Translates status codes from Zibal to Persian human-friendly messages + * Human-friendly Persian messages for Zibal result codes */ - getStatusMessage(status?: number | string): string { - const statusNum = Number(status); + getResultMessage(resultCode: number): string { const messages: Record = { - [-1]: 'در انتظار پرداخت', - [-2]: 'خطای داخلی درگاه زیبال', - 1: 'پرداخت شده و تایید شده', - 2: 'پرداخت شده - در انتظار تایید', - 3: 'تراکنش توسط کاربر لغو شد', - 4: 'شماره کارت نامعتبر است', - 5: 'موجودی حساب کافی نیست', - 6: 'رمز وارد شده اشتباه است', - 7: 'تعداد درخواست‌ها بیش از حد مجاز است', - 8: 'تعداد پرداخت اینترنتی روزانه بیش از حد مجاز است', - 9: 'مبلغ پرداخت اینترنتی روزانه بیش از حد مجاز است', - 10: 'صادرکننده کارت نامعتبر است', - 11: 'خطای سوئیچ بانکی', - 12: 'کارت قابل دسترسی نیست', - 15: 'تراکنش استرداد شده است', - 16: 'تراکنش در حال استرداد است', - 18: 'تراکنش ریورس شده است', - 21: 'پذیرنده نامعتبر است', + 100: 'با موفقیت تایید شد', + 102: 'merchant یافت نشد', + 103: 'merchant غیرفعال است', + 104: 'merchant نامعتبر است', + 105: 'مبلغ باید بیشتر از ۱,۰۰۰ ریال باشد', + 106: 'callbackUrl نامعتبر است', + 113: 'مبلغ تراکنش بیش از سقف مجاز است', + 201: 'تراکنش قبلا تایید شده است', + 202: 'سفارش پرداخت نشده یا ناموفق بوده است', + 203: 'trackId نامعتبر است', }; - return messages[statusNum] || 'وضعیت نامشخص'; + return ( + messages[resultCode] || + `خطای ناشناخته در ارتباط با درگاه (کد ${resultCode})` + ); } /** - * Translates verify result codes to Persian messages + * Human-friendly Persian messages for Zibal status codes */ - getResultMessage(result?: number): string { + getStatusMessage(statusCode: number | string): string { + const code = Number(statusCode); const messages: Record = { - 100: 'با موفقیت تایید شد.', - 102: 'مرچنت یافت نشد.', - 103: 'مرچنت غیرفعال است یا قرارداد درگاه امضا نشده است.', - 104: 'مرچنت نامعتبر است.', - 105: 'مبلغ باید بزرگتر از ۱,۰۰۰ ریال باشد.', - 106: 'آدرس بازگشت (callbackUrl) نامعتبر است.', - 113: 'مبلغ تراکنش از سقف مجاز بیشتر است.', - 114: 'کد ملی ارسالی نامعتبر است.', - 115: 'آی‌پی سرور شما در پنل کاربری زیبال ثبت نشده است.', - 201: 'تراکنش قبلا تایید شده است.', - 202: 'سفارش پرداخت نشده یا ناموفق بوده است.', - 203: 'شناسه پیگیری (trackId) نامعتبر است.', + '-1': 'در انتظار پردخت', + '-2': 'خطای داخلی درگاه', + '1': 'پرداخت شده و تایید شده', + '2': 'پرداخت شده اما هنوز تایید نشده', + '3': 'تراکنش توسط کاربر لغو شد', + '4': 'شماره کارت نامعتبر است', + '5': 'موجودی حساب کافی نیست', + '6': 'رمز کارت اشتباه است', + '7': 'تعداد دفعات ورود رمز غلط بیش از حد مجاز است', + '8': 'کارت منقضی شده است', + '9': 'مبلغ تراکنش بیش از سقف مجاز کارت است', + '10': 'صادرکننده کارت نامعتبر است', + '11': 'خطای سوییچ بانک صادرکننده', + '12': 'کارت یا حساب مسدود است', }; - return messages[result ?? 0] || 'نتیجه نامشخص تراکنش'; + return messages[code] || `وضعیت نامشخص (${statusCode})`; } } diff --git a/frontend/admin-panel/src/pages/Transactions.tsx b/frontend/admin-panel/src/pages/Transactions.tsx index e70c71d..e5e1f73 100644 --- a/frontend/admin-panel/src/pages/Transactions.tsx +++ b/frontend/admin-panel/src/pages/Transactions.tsx @@ -18,7 +18,11 @@ import { ArrowUpDown, ExternalLink, ShieldCheck, - RotateCcw + RotateCcw, + Activity, + Globe, + Monitor, + Code } from 'lucide-react'; import { toast } from 'react-hot-toast'; import api from '../services/api'; @@ -40,6 +44,10 @@ interface Transaction { message?: string | null; description?: string | null; type: string; + ipAddress?: string | null; + userAgent?: string | null; + rawRequest?: any; + rawResponse?: any; paidAt?: string | null; createdAt: string; user?: { @@ -68,9 +76,21 @@ interface Stats { successRate: number; } +interface GatewayHealth { + gatewayName: string; + status: 'ONLINE' | 'DEGRADED' | 'OFFLINE'; + latencyMs: number; + merchantConfigured: boolean; + activeMerchant: string; + message: string; + checkedAt: string; +} + export default function Transactions() { const [transactions, setTransactions] = useState([]); const [stats, setStats] = useState(null); + const [health, setHealth] = useState(null); + const [healthLoading, setHealthLoading] = useState(false); const [isLoading, setIsLoading] = useState(true); const [isReconciling, setIsReconciling] = useState(false); const [page, setPage] = useState(1); @@ -89,6 +109,7 @@ export default function Transactions() { const [selectedTx, setSelectedTx] = useState(null); const [liveInquiryLoading, setLiveInquiryLoading] = useState(false); const [liveInquiryData, setLiveInquiryData] = useState(null); + const [showRawLogs, setShowRawLogs] = useState(false); const fetchStats = async () => { try { @@ -101,6 +122,22 @@ export default function Transactions() { } }; + const fetchHealth = async (silent = false) => { + try { + if (!silent) setHealthLoading(true); + const res = await api.get('/payment/admin/health'); + if (res.data) { + setHealth(res.data); + if (!silent) toast.success(`وضعیت درگاه زیبال: ${res.data.status} (${res.data.latencyMs}ms)`); + } + } catch (e) { + console.error('Failed to fetch gateway health', e); + if (!silent) toast.error('عدم دسترسی به سرویس Health Check درگاه'); + } finally { + if (!silent) setHealthLoading(false); + } + }; + const fetchTransactions = useCallback(async () => { try { setIsLoading(true); @@ -131,6 +168,7 @@ export default function Transactions() { useEffect(() => { fetchTransactions(); fetchStats(); + fetchHealth(true); }, [fetchTransactions]); const handleCopy = (text: string, title: string) => { @@ -143,7 +181,7 @@ export default function Transactions() { setIsReconciling(true); const res = await api.post('/payment/admin/reconcile'); toast.success( - `انطباق تراکنش‌ها انجام شد: ${res.data.verified} تایید شده، ${res.data.failed} ناموفق`, + `انطباق انجام شد: ${res.data.verified} تایید شده، ${res.data.failed} ناموفق`, ); fetchTransactions(); fetchStats(); @@ -181,20 +219,63 @@ export default function Transactions() { لاگ‌ها و مدیریت تراکنش‌های مالی

- رهگیری لحظه‌ای پرداخت‌های آنلاین زیبال، کارت به کارت، کیف پول و عیب‌یابی تراکنش‌ها + رهگیری لحظه‌ای پرداخت‌های آنلاین زیبال، بررسی لاگ‌های خام و عیب‌یابی تراکنش‌ها

- +
+ + + +
+ {/* Gateway Health Monitor Banner */} + {health && ( +
+
+
+ + +
+
+
+ {health.gatewayName} + + {health.status} + +
+

{health.message}

+
+
+ +
+
+ + تاخیر: {health.latencyMs} ms +
+
+ + مرچنت: {health.activeMerchant} +
+
+
+ )} + {/* Summary Stat Cards */} {stats && (
@@ -488,6 +569,7 @@ export default function Transactions() { onClick={() => { setSelectedTx(tx); setLiveInquiryData(null); + setShowRawLogs(false); }} className="p-2 bg-gray-100 hover:bg-purple-50 text-gray-600 hover:text-purple-600 rounded-xl transition-all" title="مشاهده جزئیات و عیب‌یابی" @@ -598,6 +680,24 @@ export default function Transactions() { {selectedTx.cardNumber || 'ثبت نشده'}
+ + {selectedTx.ipAddress && ( +
+ IP کلاینت: + + {selectedTx.ipAddress} + +
+ )} + + {selectedTx.userAgent && ( +
+ User-Agent دستگاه: + + {selectedTx.userAgent} + +
+ )} {/* Error Message / Reason */} @@ -661,12 +761,48 @@ export default function Transactions() { )} + {/* Raw Request & Response Inspector Toggle */} +
+ + + {showRawLogs && ( +
+ {selectedTx.rawRequest && ( +
+

// Raw Request:

+
{JSON.stringify(selectedTx.rawRequest, null, 2)}
+
+ )} + {selectedTx.rawResponse && ( +
+

// Raw Response:

+
{JSON.stringify(selectedTx.rawResponse, null, 2)}
+
+ )} + {!selectedTx.rawRequest && !selectedTx.rawResponse && ( +

// لاگ خامی برای این تراکنش ثبت نشده است.

+ )} +
+ )} +
+ {/* Close Button */}
-
3234 nodes · 5269 edges · 285 communities
+
3257 nodes · 5318 edges · 291 communities