refactor(payment): unify package verify for callback and reconciliation job
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 10m40s

Extract VerifyUserPackagePurchasePaymentCommand with idempotency, DB lock,
and IUserPaymentLock so ZarinpalReconciliationJob cannot double-credit wallets.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
masoodafar-web
2026-06-08 22:44:12 +03:30
parent 541b0c2ce9
commit dba0b63a70
4 changed files with 371 additions and 335 deletions
@@ -1,5 +1,5 @@
using System.Collections.Generic;
using CMSMicroservice.Application.ClubMembershipCQ.Commands.ActivateClubMembership;
using CMSMicroservice.Application.PackageCQ.Commands.VerifyUserPackagePurchasePayment;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.DiscountShopCQ.Commands.CompleteOrderPayment;
using CMSMicroservice.Application.WalletCQ.Commands.VerifyDiscountWalletCharge;
@@ -251,94 +251,25 @@ public class ZarinpalReconciliationJob
throw new InvalidOperationException(
$"Cannot parse OrderId '{paymentTx.OrderId}' for package purchase {paymentTx.Id}");
var purchase = await _context.UserPackagePurchases
.Include(p => p.Package)
.FirstOrDefaultAsync(p => p.Id == purchaseId && !p.IsDeleted, ct)
?? throw new InvalidOperationException($"UserPackagePurchase #{purchaseId} not found");
var transaction = purchase.TransactionId.HasValue
? await _context.Transactions.FirstOrDefaultAsync(t => t.Id == purchase.TransactionId.Value, ct)
: null;
var amountInToman = paymentTx.Amount != 0 ? paymentTx.Amount : purchase.Amount;
var verifyResult = await _paymentGateway.VerifyPaymentAsync(
paymentTx.Authority!, "OK", (decimal)amountInToman, ct);
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 (verifyResult.IsSuccess && transaction != null)
var result = await _sender.Send(new VerifyUserPackagePurchasePaymentCommand
{
transaction.PaymentStatus = PaymentStatus.Success;
transaction.PaymentDate = DateTime.UtcNow;
transaction.RefId = verifyResult.RefId;
PurchaseId = purchaseId,
Authority = paymentTx.Authority ?? string.Empty,
Status = "OK"
}, ct);
var wallet = await _context.UserWallets
.FirstOrDefaultAsync(w => w.UserId == purchase.UserId, ct);
if (wallet == null)
{
wallet = new UserWallet { UserId = purchase.UserId };
_context.UserWallets.Add(wallet);
await _context.SaveChangesAsync(ct);
}
var discountMultiplier = purchase.Package?.DiscountMultiplier
?? throw new InvalidOperationException(
$"Package not loaded for UserPackagePurchase #{purchaseId}");
var discountAmount = (long)(purchase.Amount * (double)discountMultiplier);
wallet.Balance += purchase.Amount;
wallet.DiscountBalance += discountAmount;
_context.UserWalletHistories.Add(new UserWalletHistory
{
WalletId = wallet.Id,
CurrentBalance = wallet.Balance,
ChangeValue = purchase.Amount,
CurrentNetworkBalance = wallet.NetworkBalance,
ChangeNerworkValue = 0,
CurrentDiscountBalance = wallet.DiscountBalance,
ChangeDiscountValue = discountAmount,
IsIncrease = true,
RefrenceId = transaction.Id,
PackageId = purchase.PackageId
});
var user = await _context.Users
.FirstOrDefaultAsync(u => u.Id == purchase.UserId, ct);
if (user != null)
user.PackagePurchaseMethod = PackagePurchaseMethod.DirectPurchase;
}
else if (transaction != null)
if (!result.Success)
{
transaction.PaymentStatus = PaymentStatus.Reject;
throw new InvalidOperationException(
$"Package verify failed for purchase #{purchaseId}, authority={paymentTx.Authority}: {result.Message}");
}
await _context.SaveChangesAsync(ct);
if (verifyResult.IsSuccess)
if (result.AlreadyPaid)
{
try
{
await _sender.Send(new ActivateClubMembershipCommand
{
UserId = purchase.UserId,
ForceActivation = false
}, ct);
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"ZarinpalReconciliation: club activation failed for userId={UserId} (purchase #{PurchaseId})",
purchase.UserId, purchaseId);
}
_logger.LogInformation(
"ZarinpalReconciliation: package purchase #{PurchaseId} already verified (authority={Authority})",
purchaseId,
paymentTx.Authority);
}
}