From d62787cd995cb295d7a44aeb588764fa743316fa Mon Sep 17 00:00:00 2001 From: parsa aghaei Date: Sat, 22 Aug 2026 17:01:13 +0330 Subject: [PATCH] feat(payment): fix zibal refund api, add maskable fields, receipt modal, standardized buttons and order refund options --- backend/src/admin/admin.controller.ts | 15 + backend/src/admin/admin.service.ts | 59 + backend/src/main.ts | 3 + backend/src/payment/payment.controller.ts | 31 +- backend/src/payment/zibal.service.ts | 29 +- .../components/TransactionReceiptModal.tsx | 235 + .../admin-panel/src/components/ui/Button.tsx | 114 + .../src/components/ui/MaskableField.tsx | 96 + frontend/admin-panel/src/pages/Orders.tsx | 225 +- .../admin-panel/src/pages/Transactions.tsx | 249 +- .../admin-panel/src/pages/ZibalPortalPage.tsx | 20 +- graphify-out/.graphify_labels.json | 55 +- graphify-out/.graphify_labels.json.sig | 2 +- graphify-out/2026-08-22/.graphify_labels.json | 22 +- graphify-out/2026-08-22/GRAPH_REPORT.md | 158 +- graphify-out/2026-08-22/graph.json | 4776 +++---- graphify-out/2026-08-22/manifest.json | 1130 +- graphify-out/GRAPH_REPORT.md | 336 +- graphify-out/cache/stat-index.json | 2 +- graphify-out/graph.html | 8 +- graphify-out/graph.json | 11525 ++++++++-------- graphify-out/manifest.json | 1162 +- 22 files changed, 10934 insertions(+), 9318 deletions(-) create mode 100644 frontend/admin-panel/src/components/TransactionReceiptModal.tsx create mode 100644 frontend/admin-panel/src/components/ui/Button.tsx create mode 100644 frontend/admin-panel/src/components/ui/MaskableField.tsx diff --git a/backend/src/admin/admin.controller.ts b/backend/src/admin/admin.controller.ts index 7181779..4575239 100644 --- a/backend/src/admin/admin.controller.ts +++ b/backend/src/admin/admin.controller.ts @@ -172,6 +172,21 @@ export class AdminController { return { success: true, data: order }; } + @Post('orders/:id/refund-wallet') + @ApiOperation({ summary: 'استرداد مستقیم مبلغ سفارش به کیف پول کاربر' }) + async refundOrderToWallet( + @Param('id') id: string, + @Body('amount') amount?: number, + @Body('reason') reason?: string, + ) { + const result = await this.adminService.refundOrderToWallet( + id, + amount ? Number(amount) : undefined, + reason, + ); + return result; + } + @Get('coupons') @ApiOperation({ summary: 'لیست کد تخفیف‌ها' }) @ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' }) diff --git a/backend/src/admin/admin.service.ts b/backend/src/admin/admin.service.ts index 6a5c049..2e1b170 100644 --- a/backend/src/admin/admin.service.ts +++ b/backend/src/admin/admin.service.ts @@ -615,6 +615,65 @@ export class AdminService { }); } + async refundOrderToWallet(orderId: string, customAmount?: number, reason?: string) { + const order = await this.prisma.order.findUnique({ + where: { id: orderId }, + include: { user: true }, + }); + + if (!order) { + throw new NotFoundException('سفارش مورد نظر یافت نشد'); + } + + if (!order.userId || !order.user) { + throw new BadRequestException('این سفارش به کاربر ثبت‌نام‌شده‌ای متصل نیست'); + } + + const refundAmount = customAmount !== undefined && customAmount > 0 + ? customAmount + : Number(order.totalAmount || 0); + + if (refundAmount <= 0) { + throw new BadRequestException('مبلغ استرداد باید بیشتر از صفر باشد'); + } + + await this.prisma.$transaction(async (tx) => { + // 1. Increment user wallet balance + await tx.user.update({ + where: { id: order.userId }, + data: { + walletBalance: { increment: refundAmount }, + }, + }); + + // 2. Create wallet transaction entry + await tx.walletTransaction.create({ + data: { + userId: order.userId, + amount: refundAmount, + type: 'deposit', + status: 'completed', + description: reason || `استرداد وجه سفارش لغو شده #${order.trackingNumber || order.id.slice(0, 8)} به کیف پول`, + }, + }); + + // 3. Mark order as cancelled and note refund + await tx.order.update({ + where: { id: order.id }, + data: { + status: 'cancelled', + }, + }); + }); + + return { + success: true, + message: `مبلغ ${refundAmount.toLocaleString('fa-IR')} تومان با موفقیت به کیف پول کاربر بازگردانده شد.`, + refundAmount, + userId: order.userId, + }; + } + async getCoupons(query: PaginationQuery = {}) { const page = Number(query.page) || 1; const limit = Number(query.limit) || 10; diff --git a/backend/src/main.ts b/backend/src/main.ts index b3908c5..cd0b484 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -25,6 +25,9 @@ async function bootstrap() { const app = await NestFactory.create(AppModule); + // Trust reverse proxy (Nginx / Docker ingress) to resolve real client IP from X-Forwarded-For + app.set('trust proxy', true); + // Serve static uploads folder under both /uploads/ and /api/uploads/ app.useStaticAssets(join(process.cwd(), 'uploads'), { prefix: '/uploads/', diff --git a/backend/src/payment/payment.controller.ts b/backend/src/payment/payment.controller.ts index 75be221..1e4bbab 100644 --- a/backend/src/payment/payment.controller.ts +++ b/backend/src/payment/payment.controller.ts @@ -45,6 +45,23 @@ export class PaymentController { private readonly zibalEBankService: ZibalEBankService, ) {} + private extractRealClientIp(req: Request, fallbackIp?: string): string { + const xForwardedFor = req.headers['x-forwarded-for']; + if (xForwardedFor) { + const forwardedIps = Array.isArray(xForwardedFor) + ? xForwardedFor[0] + : xForwardedFor.split(',')[0]; + if (forwardedIps && forwardedIps.trim()) { + return forwardedIps.trim().replace(/^::ffff:/, ''); + } + } + const xRealIp = req.headers['x-real-ip']; + if (xRealIp && typeof xRealIp === 'string') { + return xRealIp.trim().replace(/^::ffff:/, ''); + } + return (fallbackIp || req.ip || '').replace(/^::ffff:/, ''); + } + @UseGuards(JwtAuthGuard) @ApiBearerAuth() @Post('zibal/initiate') @@ -56,16 +73,17 @@ export class PaymentController { description: 'خطا در ایجاد تراکنش یا نامعتبر بودن سفارش', }) async initiateOrderPayment( - @Req() req: { user: { id: string } }, + @Req() req: Request & { user: { id: string } }, @Body() body: InitiatePaymentDto, @Ip() ip: string, @Headers('user-agent') userAgent?: string, ) { + const realIp = this.extractRealClientIp(req, ip); return this.paymentService.initiateOrderPayment( req.user.id, body.orderId, body.customCallbackUrl, - { ipAddress: ip, userAgent }, + { ipAddress: realIp, userAgent }, ); } @@ -77,16 +95,17 @@ export class PaymentController { description: 'لینک هدایت به درگاه پرداخت برای شارژ کیف پول', }) async initiateWalletTopup( - @Req() req: { user: { id: string } }, + @Req() req: Request & { user: { id: string } }, @Body() body: InitiateWalletTopupDto, @Ip() ip: string, @Headers('user-agent') userAgent?: string, ) { + const realIp = this.extractRealClientIp(req, ip); return this.paymentService.initiateWalletTopup( req.user.id, Number(body.amount), body.customCallbackUrl, - { ipAddress: ip, userAgent }, + { ipAddress: realIp, userAgent }, ); } @@ -95,11 +114,13 @@ export class PaymentController { summary: 'دریافت کال‌بک بازگشت از درگاه زیبال و تایید تراکنش', }) async handleZibalCallback( + @Req() req: Request, @Query() query: ZibalCallbackQueryDto, @Res() res: Response, @Ip() ip: string, @Headers('user-agent') userAgent?: string, ) { + const realIp = this.extractRealClientIp(req, ip); const trackId = query.trackId || ''; const success = query.success; const status = query.status; @@ -119,7 +140,7 @@ export class PaymentController { success, status, orderId, - { ipAddress: ip, userAgent }, + { ipAddress: realIp, userAgent }, ); const params = new URLSearchParams({ diff --git a/backend/src/payment/zibal.service.ts b/backend/src/payment/zibal.service.ts index 208dadf..7d54f9d 100644 --- a/backend/src/payment/zibal.service.ts +++ b/backend/src/payment/zibal.service.ts @@ -540,19 +540,26 @@ export class ZibalService implements IPaymentGateway { cardNumber?: string; description?: string; }) { - const accountId = - options.accountId || - (await this.prisma.uiText.findUnique({ where: { key: 'ZIBAL_EBANK_ACCOUNT_ID' } }))?.value; + const merchant = await this.getMerchant(); + const accountIdSetting = ( + await this.prisma.uiText.findUnique({ where: { key: 'ZIBAL_EBANK_ACCOUNT_ID' } }) + )?.value; - const payload = { + const payload: Record = { + merchant: merchant || undefined, + merchantId: merchant || undefined, trackId: Number(options.trackId), - accountId: accountId || 'default', tryReverse: options.tryReverse ?? false, - amount: options.amount, - cardNumber: options.cardNumber, + amount: options.amount ? Number(options.amount) : undefined, + cardNumber: options.cardNumber || undefined, description: options.description || 'استرداد وجه سفارش در کنینا', }; + const finalAccountId = options.accountId || accountIdSetting; + if (finalAccountId && finalAccountId.trim() !== '') { + payload.accountId = finalAccountId.trim(); + } + return this.apiRequest('/v1/account/refund', 'POST', payload); } @@ -563,7 +570,10 @@ export class ZibalService implements IPaymentGateway { refundId?: number | string; transactionTrackId?: number | string; }) { + const merchant = await this.getMerchant(); return this.apiRequest('/v1/account/refund/inquiry', 'POST', { + merchant: merchant || undefined, + merchantId: merchant || undefined, refundId: params.refundId ? Number(params.refundId) : undefined, transactionTrackId: params.transactionTrackId ? Number(params.transactionTrackId) @@ -582,9 +592,12 @@ export class ZibalService implements IPaymentGateway { fromDate?: string; toDate?: string; }) { + const merchant = await this.getMerchant(); return this.apiRequest('/v1/account/refund/list', 'POST', { + merchant: merchant || undefined, + merchantId: merchant || undefined, status: filter.status, - type: filter.type || 4, + type: filter.type, page: filter.page || 1, size: filter.size || 20, fromDate: filter.fromDate, diff --git a/frontend/admin-panel/src/components/TransactionReceiptModal.tsx b/frontend/admin-panel/src/components/TransactionReceiptModal.tsx new file mode 100644 index 0000000..1213926 --- /dev/null +++ b/frontend/admin-panel/src/components/TransactionReceiptModal.tsx @@ -0,0 +1,235 @@ +import React from 'react'; +import { Printer, X, CheckCircle2, ShieldCheck, Copy } from 'lucide-react'; +import { toast } from 'react-hot-toast'; +import Button from './ui/Button'; + +export interface TransactionReceiptData { + trackId?: string | number; + refNumber?: string | number; + amountRials?: number | string; + amountTomans?: number | string; + statusText?: string; + orderNumber?: string; + orderId?: string; + cardNumber?: string; + paidAt?: string; + createdAt?: string; + fee?: number | string; + mobile?: string; + description?: string; + psp?: string; + ip?: string; +} + +interface TransactionReceiptModalProps { + isOpen: boolean; + onClose: () => void; + data: TransactionReceiptData | null; +} + +export default function TransactionReceiptModal({ + isOpen, + onClose, + data, +}: TransactionReceiptModalProps) { + if (!isOpen || !data) return null; + + const handlePrint = () => { + window.print(); + }; + + const handleCopy = (text: string, label: string) => { + if (!text) return; + navigator.clipboard.writeText(text); + toast.success(`${label} کپی شد`); + }; + + const rials = + data.amountRials !== undefined + ? Number(data.amountRials) + : (Number(data.amountTomans || 0) * 10); + const tomans = + data.amountTomans !== undefined + ? Number(data.amountTomans) + : Math.floor(Number(data.amountRials || 0) / 10); + + return ( +
+
+ {/* Modal Header */} +
+

+ + رسید تراکنش {data.trackId || ''} +

+ +
+ + {/* The Zigzag Receipt Slip (Matching Zibal screenshot exactly) */} +
+ {/* Top Jagged Border (CSS Jagged effect) */} +
+ {Array.from({ length: 24 }).map((_, i) => ( +
+ ))} +
+ + {/* Amount Display */} +
+
+ {rials.toLocaleString('fa-IR')}{' '} + ریال +
+

+ ({tomans.toLocaleString('fa-IR')} تومان) +

+
+ + {/* Data List (Aligned exactly like Zibal slip) */} +
+ {/* Track ID */} +
+ + شماره تراکنش +
+ + {/* Reference Number */} +
+ + شناسه مرجع +
+ + {/* Paid At */} +
+ + {data.paidAt || data.createdAt || '-'} + + تاریخ پرداخت +
+ + {/* Status */} +
+ + + {data.statusText || 'پرداخت موفق'} + + وضعیت +
+ + {/* Order ID */} +
+ + {data.orderNumber || data.orderId || '-'} + + شناسه سفارش +
+ + {/* Card Number */} +
+ + {data.cardNumber || '-'} + + شماره کارت +
+ + {/* Creation Date */} +
+ + {data.createdAt || data.paidAt || '-'} + + تاریخ ایجاد +
+ + {/* Mobile */} +
+ + {data.mobile || '-'} + + شماره موبایل +
+ + {/* Description */} +
+ + {data.description || 'پرداخت آنلاین - پت شاپ کنینا'} + + توضیحات +
+ + {/* PSP */} +
+ + {data.psp || 'زیبال / به‌پرداخت ملت'} + + PSP +
+ + {/* Real IP */} +
+ + IP +
+
+ + {/* Bottom Jagged Border */} +
+ {Array.from({ length: 24 }).map((_, i) => ( +
+ ))} +
+
+ + {/* Actions / Print Footer */} +
+ + +
+
+
+ ); +} diff --git a/frontend/admin-panel/src/components/ui/Button.tsx b/frontend/admin-panel/src/components/ui/Button.tsx new file mode 100644 index 0000000..f7be1e6 --- /dev/null +++ b/frontend/admin-panel/src/components/ui/Button.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { Info } from 'lucide-react'; +import Spinner from './Spinner'; + +export type ButtonVariant = + | 'primary' + | 'secondary' + | 'danger' + | 'success' + | 'warning' + | 'outline' + | 'ghost'; + +export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg'; + +export interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: ButtonVariant; + size?: ButtonSize; + isLoading?: boolean; + startIcon?: React.ComponentType<{ className?: string }>; + endIcon?: React.ComponentType<{ className?: string }>; + info?: string; + badge?: string | number; + fullWidth?: boolean; +} + +export default function Button({ + children, + variant = 'primary', + size = 'md', + isLoading = false, + startIcon: StartIcon, + endIcon: EndIcon, + info, + badge, + fullWidth = false, + className = '', + disabled, + ...props +}: ButtonProps) { + // Base styles: strict whitespace-nowrap and touch-friendly cursor + const baseClasses = + 'inline-flex items-center justify-center font-bold whitespace-nowrap transition-all select-none rounded-xl cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none focus:outline-none focus:ring-2 focus:ring-offset-2'; + + // Size styling + const sizeClasses: Record = { + xs: 'px-2.5 py-1 text-[11px] gap-1.5', + sm: 'px-3.5 py-1.5 text-xs gap-2', + md: 'px-4 py-2.5 text-xs sm:text-sm gap-2', + lg: 'px-6 py-3 text-sm sm:text-base gap-2.5 shadow-md', + }; + + // Variant styling + const variantClasses: Record = { + primary: + 'bg-indigo-600 hover:bg-indigo-700 text-white shadow-sm shadow-indigo-200 focus:ring-indigo-500 border border-indigo-600', + secondary: + 'bg-slate-100 hover:bg-slate-200 text-slate-700 focus:ring-slate-400 border border-slate-200', + danger: + 'bg-rose-600 hover:bg-rose-700 text-white shadow-sm shadow-rose-200 focus:ring-rose-500 border border-rose-600', + success: + 'bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-200 focus:ring-emerald-500 border border-emerald-600', + warning: + 'bg-amber-500 hover:bg-amber-600 text-white shadow-sm shadow-amber-200 focus:ring-amber-400 border border-amber-500', + outline: + 'bg-white hover:bg-gray-50 text-gray-700 border border-gray-300 focus:ring-indigo-500', + ghost: + 'bg-transparent hover:bg-gray-100 text-gray-600 focus:ring-gray-300 border border-transparent', + }; + + const iconSizes: Record = { + xs: 'w-3 h-3', + sm: 'w-3.5 h-3.5', + md: 'w-4 h-4', + lg: 'w-5 h-5', + }; + + return ( + + ); +} diff --git a/frontend/admin-panel/src/components/ui/MaskableField.tsx b/frontend/admin-panel/src/components/ui/MaskableField.tsx new file mode 100644 index 0000000..be4e48e --- /dev/null +++ b/frontend/admin-panel/src/components/ui/MaskableField.tsx @@ -0,0 +1,96 @@ +import React, { useState } from 'react'; +import { Eye, EyeOff, Copy, Check } from 'lucide-react'; +import { toast } from 'react-hot-toast'; + +interface MaskableFieldProps { + value?: string | number | null; + maskedValue?: string; + isMaskedByDefault?: boolean; + label?: string; + copyable?: boolean; + className?: string; + valueClassName?: string; + dir?: 'ltr' | 'rtl'; +} + +export default function MaskableField({ + value, + maskedValue, + isMaskedByDefault = true, + label, + copyable = true, + className = '', + valueClassName = '', + dir = 'ltr', +}: MaskableFieldProps) { + const [isRevealed, setIsRevealed] = useState(!isMaskedByDefault); + const [copied, setCopied] = useState(false); + + const rawVal = value !== undefined && value !== null ? String(value) : ''; + + // Create mask if not provided + const generateMask = (str: string) => { + if (!str) return '-'; + if (str.length <= 4) return '****'; + if (str.length === 16) { + // 16-digit Card: 6037-99**-****-1234 + return `${str.slice(0, 4)}-${str.slice(4, 6)}**-****-${str.slice(12)}`; + } + const visibleStart = Math.min(4, Math.floor(str.length / 3)); + const visibleEnd = Math.min(4, Math.floor(str.length / 3)); + return `${str.slice(0, visibleStart)}${'*'.repeat(Math.max(4, str.length - visibleStart - visibleEnd))}${str.slice(-visibleEnd)}`; + }; + + const displayMasked = maskedValue || generateMask(rawVal); + const currentText = isRevealed ? (rawVal || '-') : displayMasked; + + const handleCopy = (e: React.MouseEvent) => { + e.stopPropagation(); + if (!rawVal) return; + navigator.clipboard.writeText(rawVal); + setCopied(true); + toast.success(`${label || 'مقدار'} در کلیپ‌بورد کپی شد`); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+ + {currentText} + + + {rawVal && ( +
+ {/* Toggle Eye Button */} + + + {/* Copy to Clipboard Button */} + {copyable && ( + + )} +
+ )} +
+ ); +} diff --git a/frontend/admin-panel/src/pages/Orders.tsx b/frontend/admin-panel/src/pages/Orders.tsx index 3790409..bd30b07 100644 --- a/frontend/admin-panel/src/pages/Orders.tsx +++ b/frontend/admin-panel/src/pages/Orders.tsx @@ -22,12 +22,16 @@ import { CreditCard, ArrowUpDown, ArrowUp, - ArrowDown + ArrowDown, + Undo2, + Wallet, } from 'lucide-react'; +import { toast } from 'react-hot-toast'; import api from '../services/api'; import Skeleton from '../components/ui/Skeleton'; import Spinner from '../components/ui/Spinner'; import Pagination from '../components/ui/Pagination'; +import Button from '../components/ui/Button'; export interface OrderItem { id?: string; @@ -269,16 +273,79 @@ export default function Orders() { setIsSavingTracking(true); await api.put(`/admin/orders/${selectedOrder.id}/status`, { status: modalStatus, - trackingNumber: modalTrackingCode + trackingNumber: modalTrackingCode, }); await fetchOrders(); + toast.success('تغییرات سفارش با موفقیت ذخیره شد'); } catch (err) { console.error('Failed to save tracking number', err); + toast.error('خطا در ذخیره تغییرات سفارش'); } finally { setIsSavingTracking(false); } }; + // Refund Order State & Handlers + const [refundModalOrder, setRefundModalOrder] = useState(null); + const [refundTarget, setRefundTarget] = useState<'wallet' | 'zibal'>('wallet'); + const [refundAmount, setRefundAmount] = useState(''); + const [refundReason, setRefundReason] = useState(''); + const [isProcessingRefund, setIsProcessingRefund] = useState(false); + + const openOrderRefundModal = (order: Order) => { + setRefundModalOrder(order); + const amt = Number(order.totalAmount || order.total || 0); + setRefundAmount(String(amt)); + // If order was paid by wallet, default to wallet. If online, let admin choose. + setRefundTarget(order.paymentMethod === 'wallet' ? 'wallet' : 'zibal'); + setRefundReason(`استرداد سفارش #${order.trackingNumber || order.id.slice(0, 8)}`); + }; + + const handleExecuteOrderRefund = async () => { + if (!refundModalOrder) return; + try { + setIsProcessingRefund(true); + if (refundTarget === 'wallet') { + const res = await api.post(`/admin/orders/${refundModalOrder.id}/refund-wallet`, { + amount: refundAmount ? Number(refundAmount) : undefined, + reason: refundReason, + }); + toast.success(res.data?.message || 'مبلغ با موفقیت به کیف پول کاربر عودت داده شد.'); + setRefundModalOrder(null); + await fetchOrders(); + } else { + // Find transaction trackId if exists + const tx = refundModalOrder.paymentTransactions?.[0]; + const trackId = tx?.trackId; + if (!trackId) { + toast.error('شماره تراکنش زیبال (Track ID) برای این سفارش یافت نشد. لطفاً از گزینه کیف پول استفاده کنید.'); + return; + } + + const res = await api.post('/payment/admin/refund', { + trackId, + amount: refundAmount ? Number(refundAmount) * 10 : undefined, + tryReverse: true, + description: refundReason || `استرداد وجه سفارش #${refundModalOrder.trackingNumber || refundModalOrder.id.slice(0, 8)}`, + }); + + if (res.data?.result === 1 || res.data?.status === 1 || res.data?.success) { + toast.success('درخواست استرداد وجه به درگاه زیبال با موفقیت ارسال شد.'); + // Also mark order as cancelled + await api.put(`/admin/orders/${refundModalOrder.id}/status`, { status: 'cancelled' }); + setRefundModalOrder(null); + await fetchOrders(); + } else { + toast.error(res.data?.message || 'خطا در ارسال استرداد وجه به زیبال'); + } + } + } catch (err: any) { + toast.error(err?.response?.data?.message || 'خطا در انجام عملیات استرداد وجه'); + } finally { + setIsProcessingRefund(false); + } + }; + const handlePrintInvoice = (orderToPrint: Order) => { if (!orderToPrint) return; const printWindow = window.open('', '_blank'); @@ -649,24 +716,37 @@ export default function Orders() { -
+
+ + + {(order.status === 'cancelled' || order.status === 'pending_payment') && ( + + )}
@@ -886,21 +966,142 @@ export default function Orders() {
{/* Modal Footer */} -
- - + )} + + +
+
+
+ )} + + {/* Order Refund Modal (Wallet / Zibal Gateway) */} + {refundModalOrder && ( +
+
+
+

+ + استرداد وجه سفارش #{refundModalOrder.trackingNumber || refundModalOrder.id.slice(0, 8)} +

+
+ +
+ {/* Destination selector */} +
+ +
+ + + +
+
+ +
+ + setRefundAmount(e.target.value)} + placeholder="مبلغ استرداد" + className="w-full border border-gray-200 rounded-xl p-3 font-mono outline-none focus:ring-2 focus:ring-purple-500 font-bold" + dir="ltr" + /> +
+ +
+ +