6c3fbe43b1
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 9m49s
- Introduced new CreditWalletCharge enum value to TransactionType for clarity. - Implemented InitiateCreditCharge and VerifyCreditCharge methods in UserWalletService for handling credit wallet transactions. - Updated ZarinpalReconciliationJob to support credit wallet payment verification. - Enhanced Protobuf definitions with new messages and RPC methods for credit wallet operations. - Bumped Protobuf project version to reflect the addition of new features.
333 lines
14 KiB
C#
333 lines
14 KiB
C#
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;
|
|
|
|
/// <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);
|
|
}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);
|
|
}
|
|
}
|