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();