feat(payment): add GetUnverifiedAuthoritiesAsync method to IPaymentGatewayService and ZarinPalPaymentService for payment reconciliation
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 8m7s

- Introduced GetUnverifiedAuthoritiesAsync method to retrieve authorities with unverified payments.
- Implemented ZarinPal-specific logic for fetching unverified authorities from the new endpoint.
- Added background job for Zarinpal payment reconciliation every 30 minutes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
masoodafar-web
2026-05-21 18:26:49 +03:30
parent aac0d4271d
commit bc52240a41
4 changed files with 481 additions and 0 deletions
@@ -47,6 +47,17 @@ public interface IPaymentGatewayService
"درگاه پرداخت باید متد VerifyPaymentAsync با مبلغ را پیاده‌سازی کند");
}
/// <summary>
/// فهرست Authority‌هایی که کاربر پرداخت کرده ولی سیستم هنوز Verify نکرده
/// این متد برای Reconciliation در background job استفاده می‌شود.
/// پیاده‌سازی پیش‌فرض لیست خالی برمی‌گرداند (درگاه‌هایی که این API را ندارند).
/// </summary>
Task<IReadOnlyList<string>> GetUnverifiedAuthoritiesAsync(
CancellationToken cancellationToken = default)
{
return Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
}
/// <summary>
/// واریز مبلغ به حساب کاربر (برداشت از کیف پول)
/// </summary>
@@ -0,0 +1,382 @@
using CMSMicroservice.Application.ClubMembershipCQ.Commands.ActivateClubMembership;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.DiscountShopCQ.Commands.CompleteOrderPayment;
using CMSMicroservice.Application.WalletCQ.Commands.VerifyDiscountWalletCharge;
using CMSMicroservice.Application.WalletCQ.Commands.VerifyMagicWalletCharge;
using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Entities.Payment;
using CMSMicroservice.Domain.Enums;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Infrastructure.BackgroundJobs;
/// <summary>
/// Hangfire job that reconciles Zarinpal payments whose return-URL callback was never received.
///
/// Runs every 30 minutes. For each payment older than 30 minutes that is still pending in our DB:
/// 1. Cross-references with Zarinpal's unVerified list (payments received by bank but not yet
/// acknowledged by us). Falls back to a direct DB scan if ZP API returns nothing.
/// 2. Determines payment type from the stored CallbackUrl.
/// 3. Runs the same post-verification business logic that the callback endpoint would have run.
///
/// This ensures users whose browser crashed or whose network dropped after paying never lose
/// their wallet credits, package activations, or order completions.
/// </summary>
public class ZarinpalReconciliationJob
{
private readonly IApplicationDbContext _context;
private readonly IPaymentGatewayService _paymentGateway;
private readonly ISender _sender;
private readonly ILogger<ZarinpalReconciliationJob> _logger;
public ZarinpalReconciliationJob(
IApplicationDbContext context,
IPaymentGatewayService paymentGateway,
ISender sender,
ILogger<ZarinpalReconciliationJob> logger)
{
_context = context;
_paymentGateway = paymentGateway;
_sender = sender;
_logger = logger;
}
public async Task ExecuteAsync(CancellationToken ct = default)
{
_logger.LogInformation("ZarinpalReconciliationJob: started");
try
{
var pendingTxs = await ResolvePendingTransactionsAsync(ct);
if (pendingTxs.Count == 0)
{
_logger.LogInformation("ZarinpalReconciliationJob: no pending transactions to reconcile");
return;
}
_logger.LogInformation(
"ZarinpalReconciliationJob: {Count} pending transaction(s) to process", pendingTxs.Count);
var processed = 0;
var failed = 0;
foreach (var tx in pendingTxs)
{
try
{
await ProcessPaymentAsync(tx, ct);
processed++;
_logger.LogInformation(
"ZarinpalReconciliationJob: ✅ processed authority={Authority}", tx.Authority);
}
catch (Exception ex)
{
failed++;
_logger.LogError(
ex,
"ZarinpalReconciliationJob: ❌ failed for authority={Authority}, type={Type}",
tx.Authority,
DeterminePaymentType(tx.CallbackUrl ?? string.Empty));
}
}
_logger.LogInformation(
"ZarinpalReconciliationJob: finished — processed={Processed}, failed={Failed}",
processed, failed);
}
catch (Exception ex)
{
_logger.LogError(ex, "ZarinpalReconciliationJob: unexpected top-level error");
throw;
}
}
// ── Candidate resolution ─────────────────────────────────────────────────────────────────
private async Task<List<PaymentTransaction>> ResolvePendingTransactionsAsync(CancellationToken ct)
{
// Try Zarinpal's own "unVerified" list first (most accurate — only paid-but-not-verified)
var zpAuthorities = await _paymentGateway.GetUnverifiedAuthoritiesAsync(ct);
var cutoff = DateTime.UtcNow.AddMinutes(-30); // older than 30 min
var maxAge = DateTime.UtcNow.AddHours(-24); // but within 24 h (ZP window)
if (zpAuthorities.Count > 0)
{
_logger.LogInformation(
"ZarinpalReconciliationJob: ZP unVerified list has {Count} authority(ies), cross-referencing with DB",
zpAuthorities.Count);
return await _context.PaymentTransactions
.Where(pt =>
!pt.PaymentStatus &&
pt.RequestStatusCode == 100 &&
pt.Authority != null &&
zpAuthorities.Contains(pt.Authority) &&
pt.Created < cutoff &&
pt.Created > maxAge)
.ToListAsync(ct);
}
// Fallback: query our own DB for stale pending transactions and try to verify them.
// Zarinpal's verify call will return an error for any authority where payment was NOT made,
// so this is safe — we only apply business logic when ZP confirms success (code 100/101).
_logger.LogInformation(
"ZarinpalReconciliationJob: ZP API returned empty list; falling back to DB scan (Created < {Cutoff})",
cutoff);
return await _context.PaymentTransactions
.Where(pt =>
!pt.PaymentStatus &&
pt.RequestStatusCode == 100 &&
pt.Authority != null &&
pt.GatewayProvider == "zarinpal" &&
pt.Created < cutoff &&
pt.Created > maxAge)
.ToListAsync(ct);
}
// ── Payment type dispatch ────────────────────────────────────────────────────────────────
private async Task ProcessPaymentAsync(PaymentTransaction paymentTx, CancellationToken ct)
{
var authority = paymentTx.Authority!;
var callbackUrl = paymentTx.CallbackUrl ?? string.Empty;
var paymentType = DeterminePaymentType(callbackUrl);
_logger.LogInformation(
"ZarinpalReconciliation: processing type={Type}, authority={Authority}, userId={UserId}",
paymentType, authority, paymentTx.UserId);
switch (paymentType)
{
case "magic-wallet":
await _sender.Send(
new VerifyMagicWalletChargeCommand { Authority = authority, Status = "OK" }, ct);
break;
case "discount-wallet":
if (!paymentTx.UserId.HasValue)
throw new InvalidOperationException(
$"UserId is null for discount-wallet authority {authority}");
await _sender.Send(new VerifyDiscountWalletChargeCommand
{
Authority = authority,
UserId = paymentTx.UserId.Value,
Amount = paymentTx.Amount
}, ct);
break;
case "discount-order":
await ReconcileDiscountOrderAsync(paymentTx, ct);
break;
case "package":
await ReconcilePackagePurchaseAsync(paymentTx, ct);
break;
default:
await ReconcileGenericTransactionAsync(paymentTx, ct);
break;
}
}
private static string DeterminePaymentType(string callbackUrl)
{
if (callbackUrl.Contains("type=magic-wallet", StringComparison.OrdinalIgnoreCase))
return "magic-wallet";
if (callbackUrl.Contains("type=discount-wallet", StringComparison.OrdinalIgnoreCase))
return "discount-wallet";
if (callbackUrl.Contains("type=discount-order", StringComparison.OrdinalIgnoreCase))
return "discount-order";
// Package callbacks have orderId= but no type= parameter
if (callbackUrl.Contains("orderId=", StringComparison.OrdinalIgnoreCase) &&
!callbackUrl.Contains("type=", StringComparison.OrdinalIgnoreCase))
return "package";
return "generic";
}
// ── Per-type reconcile helpers ───────────────────────────────────────────────────────────
private async Task ReconcileDiscountOrderAsync(PaymentTransaction paymentTx, CancellationToken ct)
{
if (!long.TryParse(paymentTx.OrderId, out var orderId))
throw new InvalidOperationException(
$"Cannot parse OrderId '{paymentTx.OrderId}' from PaymentTransaction {paymentTx.Id}");
var order = await _context.DiscountOrders
.Include(o => o.OrderDetails)
.FirstOrDefaultAsync(o => o.Id == orderId, ct)
?? throw new InvalidOperationException($"DiscountOrder #{orderId} not found");
var transaction = order.TransactionId.HasValue
? await _context.Transactions.FirstOrDefaultAsync(t => t.Id == order.TransactionId.Value, ct)
: null;
var verifyResult = await _paymentGateway.VerifyPaymentAsync(
paymentTx.Authority!, "OK", order.GatewayAmountPaid, 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;
await _context.SaveChangesAsync(ct);
var completeResult = await _sender.Send(new CompleteOrderPaymentCommand
{
OrderId = orderId,
TransactionId = transaction?.Id ?? 0,
PaymentSuccess = verifyResult.IsSuccess,
RefId = verifyResult.TrackingCode ?? verifyResult.RefId
}, ct);
if (!completeResult.Success)
_logger.LogWarning(
"ZarinpalReconciliation: CompleteOrderPayment returned failure for order {OrderId}: {Msg}",
orderId, completeResult.Message);
}
private async Task ReconcilePackagePurchaseAsync(PaymentTransaction paymentTx, CancellationToken ct)
{
if (!long.TryParse(paymentTx.OrderId, out var purchaseId))
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)
{
transaction.PaymentStatus = 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 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)
{
transaction.PaymentStatus = PaymentStatus.Reject;
}
await _context.SaveChangesAsync(ct);
if (verifyResult.IsSuccess)
{
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);
}
}
}
private async Task ReconcileGenericTransactionAsync(PaymentTransaction paymentTx, CancellationToken ct)
{
// Generic deposits: Transaction.RefId holds the Authority before verify
var transaction = await _context.Transactions
.Where(t => t.RefId == paymentTx.Authority && !t.IsDeleted)
.FirstOrDefaultAsync(ct);
if (transaction == null)
{
_logger.LogWarning(
"ZarinpalReconciliation: no Transaction found with RefId={Authority} — skipping",
paymentTx.Authority);
return;
}
var amountInToman = paymentTx.Amount != 0 ? paymentTx.Amount : transaction.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.PaymentStatus = PaymentStatus.Success;
transaction.PaymentDate = DateTime.UtcNow;
transaction.RefId = verifyResult.RefId;
}
else
{
transaction.PaymentStatus = PaymentStatus.Reject;
}
await _context.SaveChangesAsync(ct);
}
}
@@ -30,6 +30,7 @@ public class ZarinPalPaymentService : IPaymentGatewayService
// مسیرهای API (مشترک)
private const string RequestEndpoint = "/pg/v4/payment/request.json";
private const string VerifyEndpoint = "/pg/v4/payment/verify.json";
private const string UnverifiedEndpoint = "/pg/v4/payment/unVerified.json";
private const string StartPayPath = "/pg/StartPay/";
private static readonly JsonSerializerOptions JsonOptions = new()
@@ -271,6 +272,49 @@ public class ZarinPalPaymentService : IPaymentGatewayService
}
}
/// <summary>
/// فهرست Authority‌هایی که کاربر پرداخت کرده ولی Verify نشده‌اند
/// زرین‌پال endpoint: POST /pg/v4/payment/unVerified.json
/// </summary>
public async Task<IReadOnlyList<string>> GetUnverifiedAuthoritiesAsync(
CancellationToken cancellationToken = default)
{
try
{
var requestBody = new ZarinPalUnverifiedRequest { MerchantId = _merchantId };
var json = JsonSerializer.Serialize(requestBody, JsonOptions);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync(UnverifiedEndpoint, content, cancellationToken);
var body = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogDebug("ZarinPal unVerified response: {StatusCode} - {Body}", response.StatusCode, body);
var result = JsonSerializer.Deserialize<ZarinPalUnverifiedResponse>(body, JsonOptions);
if (result?.Data?.Code == 100 && result.Data.Authorities?.Count > 0)
{
var authorities = result.Data.Authorities
.Where(a => !string.IsNullOrEmpty(a.Authority))
.Select(a => a.Authority!)
.ToList();
_logger.LogInformation(
"ZarinPal unVerified: {Count} unverified payment(s) found", authorities.Count);
return authorities;
}
_logger.LogDebug("ZarinPal unVerified: no pending payments. Code={Code}", result?.Data?.Code);
return Array.Empty<string>();
}
catch (Exception ex)
{
_logger.LogError(ex, "ZarinPal GetUnverifiedAuthorities failed — will fallback to DB scan");
return Array.Empty<string>();
}
}
/// <summary>
/// زرین‌پال Payout مستقیم ندارد — این متد NotSupported برمی‌گرداند
/// برای Payout باید از سرویس دیگری (مثل دایا) استفاده شود
@@ -339,6 +383,31 @@ public class ZarinPalPaymentService : IPaymentGatewayService
public string? Message { get; set; }
}
private class ZarinPalUnverifiedRequest
{
public string MerchantId { get; set; } = string.Empty;
}
private class ZarinPalUnverifiedResponse
{
public ZarinPalUnverifiedData? Data { get; set; }
}
private class ZarinPalUnverifiedData
{
public int? Code { get; set; }
public string? Message { get; set; }
public List<ZarinPalUnverifiedAuthority>? Authorities { get; set; }
}
private class ZarinPalUnverifiedAuthority
{
public string? Authority { get; set; }
public long? Amount { get; set; }
public string? Channel { get; set; }
public string? Date { get; set; }
}
/// <summary>
/// ZarinPal returns errors as [] (empty array) when no error, or as {...} object when there's an error.
/// This converter handles both cases.
+19
View File
@@ -433,6 +433,25 @@ using (var scope = app.Services.CreateScope())
cronExpression: "*/5 * * * *",
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Local });
app.Logger.LogInformation("✅ Hangfire recurring job 'chatika-account-activation' registered (Cron: */5 * * * * - Every 5 minutes)");
// Zarinpal Payment Reconciliation: Every 30 minutes
// Verifies payments older than 30 min whose return-URL callback was never received
var zarinpalReconciliationEnabled = app.Configuration.GetValue<bool>(
"BackgroundJobs:ZarinpalReconciliation:Enabled", true);
if (zarinpalReconciliationEnabled)
{
recurringJobManager.AddOrUpdate<CMSMicroservice.Infrastructure.BackgroundJobs.ZarinpalReconciliationJob>(
recurringJobId: "zarinpal-reconciliation",
methodCall: job => job.ExecuteAsync(CancellationToken.None),
cronExpression: "*/30 * * * *",
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Local });
app.Logger.LogInformation("✅ Hangfire recurring job 'zarinpal-reconciliation' registered (Cron: */30 * * * * - Every 30 minutes)");
}
else
{
recurringJobManager.RemoveIfExists("zarinpal-reconciliation");
app.Logger.LogInformation("⚠️ Hangfire recurring job 'zarinpal-reconciliation' is DISABLED in configuration");
}
}
app.Run();