fix(payment): prevent duplicate wallet credit on package verify
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 11m29s

Add idempotent verify with serializable transaction and wallet-history check
to stop concurrent callback/race from charging the wallet twice.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
masoodafar-web
2026-06-08 01:32:47 +03:30
parent 4f2d313d5e
commit f08d3ac78f
@@ -15,6 +15,7 @@ using AppModels = CMSMicroservice.Application.Common.Models;
using Grpc.Core; using Grpc.Core;
using Google.Protobuf.WellKnownTypes; using Google.Protobuf.WellKnownTypes;
using System.Collections.Generic; using System.Collections.Generic;
using System.Data;
using System.Linq; using System.Linq;
using CMSMicroservice.Protobuf.Protos; using CMSMicroservice.Protobuf.Protos;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -242,66 +243,137 @@ public class PackageService : PackageContract.PackageContractBase
public override async Task<CustomerVerifyPackagePurchaseResponse> CustomerVerifyPackagePurchase(CustomerVerifyPackagePurchaseRequest request, ServerCallContext context) public override async Task<CustomerVerifyPackagePurchaseResponse> CustomerVerifyPackagePurchase(CustomerVerifyPackagePurchaseRequest request, ServerCallContext context)
{ {
// Find purchase record var ct = context.CancellationToken;
var purchase = await _context.UserPackagePurchases var purchase = await _context.UserPackagePurchases
.Include(p => p.Package) .Include(p => p.Package)
.Where(p => p.Id == request.OrderId && !p.IsDeleted) .Where(p => p.Id == request.OrderId && !p.IsDeleted)
.FirstOrDefaultAsync(context.CancellationToken); .FirstOrDefaultAsync(ct);
if (purchase == null) if (purchase == null)
throw new RpcException(new Status(StatusCode.NotFound, "سفارش یافت نشد")); throw new RpcException(new Status(StatusCode.NotFound, "سفارش یافت نشد"));
// Find the associated transaction
var transaction = purchase.TransactionId.HasValue var transaction = purchase.TransactionId.HasValue
? await _context.Transactions ? await _context.Transactions
.Where(t => t.Id == purchase.TransactionId.Value) .Where(t => t.Id == purchase.TransactionId.Value)
.FirstOrDefaultAsync(context.CancellationToken) .FirstOrDefaultAsync(ct)
: null; : null;
// If status from gateway callback is not OK var paymentTx = !string.IsNullOrEmpty(request.Authority)
? await _context.PaymentTransactions
.FirstOrDefaultAsync(pt => pt.Authority == request.Authority, ct)
: null;
// Idempotency: already verified — never credit wallet again
if (IsPackagePaymentAlreadyCompleted(transaction, paymentTx))
{
return BuildVerifyPackagePurchaseResponse(
purchase, transaction, paymentTx, alreadyPaid: true);
}
if (request.Status != "OK") if (request.Status != "OK")
{ {
if (transaction != null) if (transaction != null && transaction.PaymentStatus != Domain.Enums.PaymentStatus.Success)
{ {
transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject; transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject;
await _context.SaveChangesAsync(context.CancellationToken); await _context.SaveChangesAsync(ct);
} }
return new CustomerVerifyPackagePurchaseResponse return new CustomerVerifyPackagePurchaseResponse
{ {
Success = false, Success = false,
Message = "پرداخت توسط کاربر لغو شد" Message = "پرداخت توسط کاربر لغو شد"
}; };
} }
// واکشی PaymentTransaction برای گرفتن مبلغ (تومان) جهت verify
var paymentTx = await _context.PaymentTransactions
.FirstOrDefaultAsync(pt => pt.Authority == request.Authority, context.CancellationToken);
var amountInToman = paymentTx?.Amount ?? purchase.Amount; var amountInToman = paymentTx?.Amount ?? purchase.Amount;
// Verify with payment gateway (مبلغ به تومان — سرویس زرین‌پال خودش ×۱۰ می‌کنه)
var verifyResult = await _paymentGateway.VerifyPaymentAsync( var verifyResult = await _paymentGateway.VerifyPaymentAsync(
request.Authority, request.Status, (decimal)amountInToman, context.CancellationToken); request.Authority, request.Status, (decimal)amountInToman, ct);
if (paymentTx != null)
if (!verifyResult.IsSuccess)
{ {
paymentTx.PaymentStatus = verifyResult.IsSuccess; if (paymentTx != null)
paymentTx.VerificationStatusCode = verifyResult.VerificationCode; {
paymentTx.VerificationStatusMessage = verifyResult.Message; paymentTx.PaymentStatus = false;
paymentTx.CardPan = verifyResult.CardPan; paymentTx.VerificationStatusCode = verifyResult.VerificationCode;
paymentTx.CardHash = verifyResult.CardHash; paymentTx.VerificationStatusMessage = verifyResult.Message;
paymentTx.RefId = verifyResult.TrackingCode; }
if (transaction != null)
transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject;
await _context.SaveChangesAsync(ct);
return new CustomerVerifyPackagePurchaseResponse
{
Success = false,
Message = verifyResult.Message ?? "خرید پکیج ناموفق بود",
TransactionId = transaction?.Id ?? 0
};
} }
if (verifyResult.IsSuccess && transaction != null) // Serializable transaction prevents concurrent double-credit (~1s race from duplicate callbacks)
await using var dbTx = await _context.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);
try
{ {
// Reload inside lock scope
if (purchase.TransactionId.HasValue)
{
transaction = await _context.Transactions
.FirstOrDefaultAsync(t => t.Id == purchase.TransactionId.Value, ct);
}
if (!string.IsNullOrEmpty(request.Authority))
{
paymentTx = await _context.PaymentTransactions
.FirstOrDefaultAsync(pt => pt.Authority == request.Authority, ct);
}
if (IsPackagePaymentAlreadyCompleted(transaction, paymentTx))
{
await dbTx.CommitAsync(ct);
return BuildVerifyPackagePurchaseResponse(
purchase, transaction, paymentTx, alreadyPaid: true);
}
if (transaction == null)
throw new RpcException(new Status(StatusCode.Internal, "تراکنش سفارش یافت نشد"));
// Wallet history is the idempotency key for financial side-effects
var alreadyCredited = await _context.UserWalletHistories
.AnyAsync(h => h.RefrenceId == transaction.Id && !h.IsDeleted, ct);
if (alreadyCredited)
{
transaction.PaymentStatus = Domain.Enums.PaymentStatus.Success;
transaction.PaymentDate ??= DateTime.UtcNow;
if (paymentTx != null)
{
paymentTx.PaymentStatus = true;
paymentTx.VerificationStatusCode = verifyResult.VerificationCode;
paymentTx.VerificationStatusMessage = verifyResult.Message;
paymentTx.RefId = verifyResult.TrackingCode;
}
await _context.SaveChangesAsync(ct);
await dbTx.CommitAsync(ct);
return BuildVerifyPackagePurchaseResponse(
purchase, transaction, paymentTx, alreadyPaid: true);
}
if (paymentTx != null)
{
paymentTx.PaymentStatus = true;
paymentTx.VerificationStatusCode = verifyResult.VerificationCode;
paymentTx.VerificationStatusMessage = verifyResult.Message;
paymentTx.CardPan = verifyResult.CardPan;
paymentTx.CardHash = verifyResult.CardHash;
paymentTx.RefId = verifyResult.TrackingCode;
}
transaction.PaymentStatus = Domain.Enums.PaymentStatus.Success; transaction.PaymentStatus = Domain.Enums.PaymentStatus.Success;
transaction.PaymentDate = DateTime.UtcNow; transaction.PaymentDate = DateTime.UtcNow;
transaction.RefId = verifyResult.RefId; transaction.RefId = verifyResult.RefId;
// شارژ کیف پول کاربر
var wallet = await _context.UserWallets var wallet = await _context.UserWallets
.FirstOrDefaultAsync(w => w.UserId == purchase.UserId, context.CancellationToken); .FirstOrDefaultAsync(w => w.UserId == purchase.UserId, ct);
if (wallet == null) if (wallet == null)
{ {
@@ -313,7 +385,7 @@ public class PackageService : PackageContract.PackageContractBase
NetworkBalance = 0 NetworkBalance = 0
}; };
_context.UserWallets.Add(wallet); _context.UserWallets.Add(wallet);
await _context.SaveChangesAsync(context.CancellationToken); await _context.SaveChangesAsync(ct);
} }
var discountMultiplier = purchase.Package?.DiscountMultiplier var discountMultiplier = purchase.Package?.DiscountMultiplier
@@ -322,8 +394,7 @@ public class PackageService : PackageContract.PackageContractBase
wallet.Balance += purchase.Amount; wallet.Balance += purchase.Amount;
wallet.DiscountBalance += discountAmount; wallet.DiscountBalance += discountAmount;
// ثبت لاگ کیف پول _context.UserWalletHistories.Add(new CMSMicroservice.Domain.Entities.UserWalletHistory
var walletLog = new CMSMicroservice.Domain.Entities.UserWalletHistory
{ {
WalletId = wallet.Id, WalletId = wallet.Id,
CurrentBalance = wallet.Balance, CurrentBalance = wallet.Balance,
@@ -335,23 +406,22 @@ public class PackageService : PackageContract.PackageContractBase
IsIncrease = true, IsIncrease = true,
RefrenceId = transaction.Id, RefrenceId = transaction.Id,
PackageId = purchase.PackageId PackageId = purchase.PackageId
}; });
_context.UserWalletHistories.Add(walletLog);
// به‌روزرسانی کاربر
var user = await _context.Users var user = await _context.Users
.FirstOrDefaultAsync(u => u.Id == purchase.UserId, context.CancellationToken); .FirstOrDefaultAsync(u => u.Id == purchase.UserId, ct);
if (user != null) if (user != null)
user.PackagePurchaseMethod = Domain.Enums.PackagePurchaseMethod.DirectPurchase; user.PackagePurchaseMethod = Domain.Enums.PackagePurchaseMethod.DirectPurchase;
await _context.SaveChangesAsync(ct);
await dbTx.CommitAsync(ct);
} }
else if (transaction != null) catch
{ {
transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject; await dbTx.RollbackAsync(ct);
throw;
} }
await _context.SaveChangesAsync(context.CancellationToken);
// فعالسازی خودکار باشگاه مشتریان بعد از تأیید پرداخت موفق
if (verifyResult.IsSuccess) if (verifyResult.IsSuccess)
{ {
try try
@@ -360,31 +430,54 @@ public class PackageService : PackageContract.PackageContractBase
{ {
UserId = purchase.UserId, UserId = purchase.UserId,
ForceActivation = false ForceActivation = false
}, context.CancellationToken); }, ct);
} }
catch (Exception ex) catch (Exception ex)
{ {
// لاگ خطا ولی پرداخت موفق بوده — کاربر می‌تونه دستی فعالسازی کنه
System.Console.WriteLine($"Auto club activation failed for UserId {purchase.UserId}: {ex.Message}"); System.Console.WriteLine($"Auto club activation failed for UserId {purchase.UserId}: {ex.Message}");
} }
} }
return BuildVerifyPackagePurchaseResponse(
purchase, transaction, paymentTx, alreadyPaid: false, verifyResult.RefId);
}
private static bool IsPackagePaymentAlreadyCompleted(
CMSMicroservice.Domain.Entities.Transaction? transaction,
PaymentTransaction? paymentTx)
{
return transaction?.PaymentStatus == Domain.Enums.PaymentStatus.Success
|| paymentTx?.PaymentStatus == true;
}
private static CustomerVerifyPackagePurchaseResponse BuildVerifyPackagePurchaseResponse(
CMSMicroservice.Domain.Entities.UserPackagePurchase purchase,
CMSMicroservice.Domain.Entities.Transaction? transaction,
PaymentTransaction? paymentTx,
bool alreadyPaid,
string? referenceCode = null)
{
var refCode = referenceCode
?? paymentTx?.RefId
?? transaction?.RefId
?? string.Empty;
return new CustomerVerifyPackagePurchaseResponse return new CustomerVerifyPackagePurchaseResponse
{ {
Success = verifyResult.IsSuccess, Success = true,
Message = verifyResult.IsSuccess ? "خرید پکیج با موفقیت تایید شد" : (verifyResult.Message ?? "خرید پکیج ناموفق بود"), Message = alreadyPaid ? "پرداخت قبلاً تأیید شده بود" : "خرید پکیج با موفقیت تایید شد",
TransactionId = transaction?.Id ?? 0, TransactionId = transaction?.Id ?? 0,
ReferenceCode = verifyResult.RefId ?? string.Empty, ReferenceCode = refCode,
PurchaseInfo = verifyResult.IsSuccess ? new PackagePurchaseInfo PurchaseInfo = new PackagePurchaseInfo
{ {
PackageId = purchase.PackageId, PackageId = purchase.PackageId,
PackageName = purchase.Package?.Title ?? string.Empty, PackageName = purchase.Package?.Title ?? string.Empty,
AmountPaid = purchase.Amount, AmountPaid = purchase.Amount,
PurchaseDate = Timestamp.FromDateTime(DateTime.SpecifyKind(purchase.PurchasedAt, DateTimeKind.Utc)) PurchaseDate = Timestamp.FromDateTime(DateTime.SpecifyKind(purchase.PurchasedAt, DateTimeKind.Utc))
} : null }
}; };
} }
private long GetCurrentUserId() private long GetCurrentUserId()
{ {
if (long.TryParse(_currentUserService.UserId, out var userId) && userId > 0) if (long.TryParse(_currentUserService.UserId, out var userId) && userId > 0)