refactor(payment): unify package verify for callback and reconciliation job
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 10m40s
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:
@@ -9,6 +9,7 @@ using CMSMicroservice.Application.PackageCQ.Queries.GetUserPackageStatus;
|
||||
using CMSMicroservice.Application.PackageCQ.Queries.GetCustomerPackages;
|
||||
using CMSMicroservice.Application.PackageCQ.Queries.GetCustomerPackageDetails;
|
||||
using CMSMicroservice.Application.PackageCQ.Queries.GetCustomerPurchaseHistory;
|
||||
using CMSMicroservice.Application.PackageCQ.Commands.VerifyUserPackagePurchasePayment;
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Application.Common;
|
||||
using CMSMicroservice.Application.Common.Exceptions;
|
||||
@@ -17,7 +18,6 @@ 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;
|
||||
@@ -265,29 +265,20 @@ 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)
|
||||
{
|
||||
var ct = context.CancellationToken;
|
||||
|
||||
var purchaseUserId = await _context.UserPackagePurchases
|
||||
.Where(p => p.Id == request.OrderId && !p.IsDeleted)
|
||||
.Select(p => (long?)p.UserId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (purchaseUserId is null or 0)
|
||||
throw new RpcException(new Status(StatusCode.NotFound, "سفارش یافت نشد"));
|
||||
|
||||
var verifyKey = !string.IsNullOrEmpty(request.Authority)
|
||||
? request.Authority
|
||||
: request.OrderId.ToString();
|
||||
|
||||
try
|
||||
{
|
||||
return await _paymentLock.ExecuteAsync(
|
||||
PaymentLockScopes.Verify(purchaseUserId.Value, verifyKey),
|
||||
PaymentLockStrategy.WaitForRelease,
|
||||
lockCt => CustomerVerifyPackagePurchaseCore(request, lockCt),
|
||||
ct);
|
||||
var result = await _sender.Send(new VerifyUserPackagePurchasePaymentCommand
|
||||
{
|
||||
PurchaseId = request.OrderId,
|
||||
Authority = request.Authority ?? string.Empty,
|
||||
Status = request.Status ?? "NOK"
|
||||
}, context.CancellationToken);
|
||||
|
||||
return MapVerifyPackagePurchaseResponse(result);
|
||||
}
|
||||
catch (PaymentInProgressException ex)
|
||||
{
|
||||
@@ -295,240 +286,25 @@ public class PackageService : PackageContract.PackageContractBase
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<CustomerVerifyPackagePurchaseResponse> CustomerVerifyPackagePurchaseCore(
|
||||
CustomerVerifyPackagePurchaseRequest request,
|
||||
CancellationToken ct)
|
||||
private static CustomerVerifyPackagePurchaseResponse MapVerifyPackagePurchaseResponse(
|
||||
VerifyUserPackagePurchasePaymentResult result)
|
||||
{
|
||||
var purchase = await _context.UserPackagePurchases
|
||||
.Include(p => p.Package)
|
||||
.Where(p => p.Id == request.OrderId && !p.IsDeleted)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (purchase == null)
|
||||
throw new RpcException(new Status(StatusCode.NotFound, "سفارش یافت نشد"));
|
||||
|
||||
var transaction = purchase.TransactionId.HasValue
|
||||
? await _context.Transactions
|
||||
.Where(t => t.Id == purchase.TransactionId.Value)
|
||||
.FirstOrDefaultAsync(ct)
|
||||
: null;
|
||||
|
||||
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 && transaction.PaymentStatus != Domain.Enums.PaymentStatus.Success)
|
||||
{
|
||||
transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject;
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
return new CustomerVerifyPackagePurchaseResponse
|
||||
{
|
||||
Success = false,
|
||||
Message = "پرداخت توسط کاربر لغو شد"
|
||||
};
|
||||
}
|
||||
|
||||
var amountInToman = paymentTx?.Amount ?? purchase.Amount;
|
||||
var verifyResult = await _paymentGateway.VerifyPaymentAsync(
|
||||
request.Authority, request.Status, (decimal)amountInToman, ct);
|
||||
|
||||
if (!verifyResult.IsSuccess)
|
||||
{
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
// 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, ct);
|
||||
|
||||
if (wallet == null)
|
||||
{
|
||||
wallet = new CMSMicroservice.Domain.Entities.UserWallet
|
||||
{
|
||||
UserId = purchase.UserId,
|
||||
Balance = 0,
|
||||
DiscountBalance = 0,
|
||||
NetworkBalance = 0
|
||||
};
|
||||
_context.UserWallets.Add(wallet);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
var discountMultiplier = purchase.Package?.DiscountMultiplier
|
||||
?? throw new RpcException(new Status(StatusCode.Internal, "پکیج سفارش یافت نشد"));
|
||||
var discountAmount = (long)(purchase.Amount * (double)discountMultiplier);
|
||||
wallet.Balance += purchase.Amount;
|
||||
wallet.DiscountBalance += discountAmount;
|
||||
|
||||
_context.UserWalletHistories.Add(new CMSMicroservice.Domain.Entities.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 = Domain.Enums.PackagePurchaseMethod.DirectPurchase;
|
||||
|
||||
await _context.SaveChangesAsync(ct);
|
||||
await dbTx.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await dbTx.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
|
||||
if (verifyResult.IsSuccess)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _sender.Send(new CMSMicroservice.Application.ClubMembershipCQ.Commands.ActivateClubMembership.ActivateClubMembershipCommand
|
||||
{
|
||||
UserId = purchase.UserId,
|
||||
ForceActivation = false
|
||||
}, 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 = true,
|
||||
Message = alreadyPaid ? "پرداخت قبلاً تأیید شده بود" : "خرید پکیج با موفقیت تایید شد",
|
||||
TransactionId = transaction?.Id ?? 0,
|
||||
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))
|
||||
}
|
||||
Success = result.Success,
|
||||
Message = result.Message,
|
||||
TransactionId = result.TransactionId,
|
||||
ReferenceCode = result.ReferenceCode,
|
||||
PurchaseInfo = result.Success
|
||||
? new PackagePurchaseInfo
|
||||
{
|
||||
PackageId = result.PackageId,
|
||||
PackageName = result.PackageName,
|
||||
AmountPaid = result.AmountPaid,
|
||||
PurchaseDate = Timestamp.FromDateTime(
|
||||
DateTime.SpecifyKind(result.PurchasedAt, DateTimeKind.Utc))
|
||||
}
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user