canina/backend/src/payment/payment.service.ts
2026-08-16 12:24:20 +03:30

860 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();
}
}