From f08d3ac78ffb7ffe281dc9b3e5220ab76cf20a88 Mon Sep 17 00:00:00 2001 From: masoodafar-web Date: Mon, 8 Jun 2026 01:32:47 +0330 Subject: [PATCH] fix(payment): prevent duplicate wallet credit on package verify Add idempotent verify with serializable transaction and wallet-history check to stop concurrent callback/race from charging the wallet twice. Co-authored-by: Cursor --- .../Services/PackageService.cs | 197 +++++++++++++----- 1 file changed, 145 insertions(+), 52 deletions(-) diff --git a/src/CMSMicroservice.WebApi/Services/PackageService.cs b/src/CMSMicroservice.WebApi/Services/PackageService.cs index 3cbae2f..45ce117 100644 --- a/src/CMSMicroservice.WebApi/Services/PackageService.cs +++ b/src/CMSMicroservice.WebApi/Services/PackageService.cs @@ -15,6 +15,7 @@ using AppModels = CMSMicroservice.Application.Common.Models; using Grpc.Core; using Google.Protobuf.WellKnownTypes; using System.Collections.Generic; +using System.Data; using System.Linq; using CMSMicroservice.Protobuf.Protos; using Microsoft.EntityFrameworkCore; @@ -242,66 +243,137 @@ public class PackageService : PackageContract.PackageContractBase public override async Task CustomerVerifyPackagePurchase(CustomerVerifyPackagePurchaseRequest request, ServerCallContext context) { - // Find purchase record + var ct = context.CancellationToken; + var purchase = await _context.UserPackagePurchases .Include(p => p.Package) .Where(p => p.Id == request.OrderId && !p.IsDeleted) - .FirstOrDefaultAsync(context.CancellationToken); - + .FirstOrDefaultAsync(ct); + if (purchase == null) throw new RpcException(new Status(StatusCode.NotFound, "سفارش یافت نشد")); - - // Find the associated transaction + var transaction = purchase.TransactionId.HasValue ? await _context.Transactions .Where(t => t.Id == purchase.TransactionId.Value) - .FirstOrDefaultAsync(context.CancellationToken) + .FirstOrDefaultAsync(ct) : 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 (transaction != null) + if (transaction != null && transaction.PaymentStatus != Domain.Enums.PaymentStatus.Success) { transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject; - await _context.SaveChangesAsync(context.CancellationToken); + await _context.SaveChangesAsync(ct); } - + return new CustomerVerifyPackagePurchaseResponse { Success = false, Message = "پرداخت توسط کاربر لغو شد" }; } - - // واکشی PaymentTransaction برای گرفتن مبلغ (تومان) جهت verify - var paymentTx = await _context.PaymentTransactions - .FirstOrDefaultAsync(pt => pt.Authority == request.Authority, context.CancellationToken); - + var amountInToman = paymentTx?.Amount ?? purchase.Amount; - - // Verify with payment gateway (مبلغ به تومان — سرویس زرین‌پال خودش ×۱۰ می‌کنه) var verifyResult = await _paymentGateway.VerifyPaymentAsync( - request.Authority, request.Status, (decimal)amountInToman, context.CancellationToken); - if (paymentTx != null) + request.Authority, request.Status, (decimal)amountInToman, ct); + + if (!verifyResult.IsSuccess) { - paymentTx.PaymentStatus = verifyResult.IsSuccess; - paymentTx.VerificationStatusCode = verifyResult.VerificationCode; - paymentTx.VerificationStatusMessage = verifyResult.Message; - paymentTx.CardPan = verifyResult.CardPan; - paymentTx.CardHash = verifyResult.CardHash; - paymentTx.RefId = verifyResult.TrackingCode; + if (paymentTx != null) + { + paymentTx.PaymentStatus = false; + paymentTx.VerificationStatusCode = verifyResult.VerificationCode; + paymentTx.VerificationStatusMessage = verifyResult.Message; + } + 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.PaymentDate = DateTime.UtcNow; transaction.RefId = verifyResult.RefId; - // شارژ کیف پول کاربر var wallet = await _context.UserWallets - .FirstOrDefaultAsync(w => w.UserId == purchase.UserId, context.CancellationToken); + .FirstOrDefaultAsync(w => w.UserId == purchase.UserId, ct); if (wallet == null) { @@ -313,7 +385,7 @@ public class PackageService : PackageContract.PackageContractBase NetworkBalance = 0 }; _context.UserWallets.Add(wallet); - await _context.SaveChangesAsync(context.CancellationToken); + await _context.SaveChangesAsync(ct); } var discountMultiplier = purchase.Package?.DiscountMultiplier @@ -322,8 +394,7 @@ public class PackageService : PackageContract.PackageContractBase wallet.Balance += purchase.Amount; wallet.DiscountBalance += discountAmount; - // ثبت لاگ کیف پول - var walletLog = new CMSMicroservice.Domain.Entities.UserWalletHistory + _context.UserWalletHistories.Add(new CMSMicroservice.Domain.Entities.UserWalletHistory { WalletId = wallet.Id, CurrentBalance = wallet.Balance, @@ -335,23 +406,22 @@ public class PackageService : PackageContract.PackageContractBase IsIncrease = true, RefrenceId = transaction.Id, PackageId = purchase.PackageId - }; - _context.UserWalletHistories.Add(walletLog); + }); - // به‌روزرسانی کاربر var user = await _context.Users - .FirstOrDefaultAsync(u => u.Id == purchase.UserId, context.CancellationToken); + .FirstOrDefaultAsync(u => u.Id == purchase.UserId, ct); if (user != null) 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) { try @@ -360,31 +430,54 @@ public class PackageService : PackageContract.PackageContractBase { UserId = purchase.UserId, ForceActivation = false - }, context.CancellationToken); + }, ct); } catch (Exception ex) { - // لاگ خطا ولی پرداخت موفق بوده — کاربر می‌تونه دستی فعالسازی کنه 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 { - Success = verifyResult.IsSuccess, - Message = verifyResult.IsSuccess ? "خرید پکیج با موفقیت تایید شد" : (verifyResult.Message ?? "خرید پکیج ناموفق بود"), + Success = true, + Message = alreadyPaid ? "پرداخت قبلاً تأیید شده بود" : "خرید پکیج با موفقیت تایید شد", TransactionId = transaction?.Id ?? 0, - ReferenceCode = verifyResult.RefId ?? string.Empty, - PurchaseInfo = verifyResult.IsSuccess ? new PackagePurchaseInfo + ReferenceCode = refCode, + PurchaseInfo = new PackagePurchaseInfo { PackageId = purchase.PackageId, PackageName = purchase.Package?.Title ?? string.Empty, AmountPaid = purchase.Amount, PurchaseDate = Timestamp.FromDateTime(DateTime.SpecifyKind(purchase.PurchasedAt, DateTimeKind.Utc)) - } : null + } }; } - + private long GetCurrentUserId() { if (long.TryParse(_currentUserService.UserId, out var userId) && userId > 0)