diff --git a/src/CMSMicroservice.Application/Common/Interfaces/IPaymentGatewayService.cs b/src/CMSMicroservice.Application/Common/Interfaces/IPaymentGatewayService.cs
index bb9a668..704749b 100644
--- a/src/CMSMicroservice.Application/Common/Interfaces/IPaymentGatewayService.cs
+++ b/src/CMSMicroservice.Application/Common/Interfaces/IPaymentGatewayService.cs
@@ -47,6 +47,17 @@ public interface IPaymentGatewayService
"درگاه پرداخت باید متد VerifyPaymentAsync با مبلغ را پیادهسازی کند");
}
+ ///
+ /// فهرست Authorityهایی که کاربر پرداخت کرده ولی سیستم هنوز Verify نکرده
+ /// این متد برای Reconciliation در background job استفاده میشود.
+ /// پیادهسازی پیشفرض لیست خالی برمیگرداند (درگاههایی که این API را ندارند).
+ ///
+ Task> GetUnverifiedAuthoritiesAsync(
+ CancellationToken cancellationToken = default)
+ {
+ return Task.FromResult>(Array.Empty());
+ }
+
///
/// واریز مبلغ به حساب کاربر (برداشت از کیف پول)
///
diff --git a/src/CMSMicroservice.Infrastructure/BackgroundJobs/ZarinpalReconciliationJob.cs b/src/CMSMicroservice.Infrastructure/BackgroundJobs/ZarinpalReconciliationJob.cs
new file mode 100644
index 0000000..338ea03
--- /dev/null
+++ b/src/CMSMicroservice.Infrastructure/BackgroundJobs/ZarinpalReconciliationJob.cs
@@ -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;
+
+///
+/// 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.
+///
+public class ZarinpalReconciliationJob
+{
+ private readonly IApplicationDbContext _context;
+ private readonly IPaymentGatewayService _paymentGateway;
+ private readonly ISender _sender;
+ private readonly ILogger _logger;
+
+ public ZarinpalReconciliationJob(
+ IApplicationDbContext context,
+ IPaymentGatewayService paymentGateway,
+ ISender sender,
+ ILogger 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> 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);
+ }
+}
diff --git a/src/CMSMicroservice.Infrastructure/Services/Payment/ZarinPalPaymentService.cs b/src/CMSMicroservice.Infrastructure/Services/Payment/ZarinPalPaymentService.cs
index a0b05ef..23bbdec 100644
--- a/src/CMSMicroservice.Infrastructure/Services/Payment/ZarinPalPaymentService.cs
+++ b/src/CMSMicroservice.Infrastructure/Services/Payment/ZarinPalPaymentService.cs
@@ -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
}
}
+ ///
+ /// فهرست Authorityهایی که کاربر پرداخت کرده ولی Verify نشدهاند
+ /// زرینپال endpoint: POST /pg/v4/payment/unVerified.json
+ ///
+ public async Task> 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(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();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "ZarinPal GetUnverifiedAuthorities failed — will fallback to DB scan");
+ return Array.Empty();
+ }
+ }
+
///
/// زرینپال 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? 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; }
+ }
+
///
/// ZarinPal returns errors as [] (empty array) when no error, or as {...} object when there's an error.
/// This converter handles both cases.
diff --git a/src/CMSMicroservice.WebApi/Program.cs b/src/CMSMicroservice.WebApi/Program.cs
index 55f800b..0773303 100644
--- a/src/CMSMicroservice.WebApi/Program.cs
+++ b/src/CMSMicroservice.WebApi/Program.cs
@@ -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(
+ "BackgroundJobs:ZarinpalReconciliation:Enabled", true);
+ if (zarinpalReconciliationEnabled)
+ {
+ recurringJobManager.AddOrUpdate(
+ 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();