using System.Collections.Generic; using CMSMicroservice.Application.PackageCQ.Commands.VerifyUserPackagePurchasePayment; using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Application.DiscountShopCQ.Commands.CompleteOrderPayment; using CMSMicroservice.Application.WalletCQ.Commands.VerifyDiscountWalletCharge; using CMSMicroservice.Application.WalletCQ.Commands.VerifyCreditWalletCharge; 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); }else{ _logger.LogInformation( "ZarinpalReconciliationJob: ZP unVerified list is empty"); } // 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 "credit-wallet": if (!paymentTx.UserId.HasValue) throw new InvalidOperationException( $"UserId is null for credit-wallet authority {authority}"); await _sender.Send(new VerifyCreditWalletChargeCommand { 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=credit-wallet", StringComparison.OrdinalIgnoreCase)) return "credit-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 result = await _sender.Send(new VerifyUserPackagePurchasePaymentCommand { PurchaseId = purchaseId, Authority = paymentTx.Authority ?? string.Empty, Status = "OK" }, ct); if (!result.Success) { throw new InvalidOperationException( $"Package verify failed for purchase #{purchaseId}, authority={paymentTx.Authority}: {result.Message}"); } if (result.AlreadyPaid) { _logger.LogInformation( "ZarinpalReconciliation: package purchase #{PurchaseId} already verified (authority={Authority})", purchaseId, paymentTx.Authority); } } 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); } }