860 lines
26 KiB
TypeScript
860 lines
26 KiB
TypeScript
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();
|
||
}
|
||
}
|