fix(payment): prevent duplicate wallet credit on package verify
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 11m29s
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:
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user