diff --git a/src/CMSMicroservice.Infrastructure/BackgroundServices/ExpirePendingOrdersService.cs b/src/CMSMicroservice.Infrastructure/BackgroundServices/ExpirePendingOrdersService.cs
new file mode 100644
index 0000000..a059bc4
--- /dev/null
+++ b/src/CMSMicroservice.Infrastructure/BackgroundServices/ExpirePendingOrdersService.cs
@@ -0,0 +1,116 @@
+using CMSMicroservice.Application.Common.Interfaces;
+using CMSMicroservice.Domain.Enums;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace CMSMicroservice.Infrastructure.BackgroundServices;
+
+///
+/// سرویس پسزمینه برای منقضی کردن سفارشات تخفیفی که بیش از ۳۰ دقیقه در وضعیت Pending ماندهاند.
+/// موجودی رزرو شده آزاد و وضعیت پرداخت به Reject تغییر میکند.
+///
+public class ExpirePendingOrdersService : BackgroundService
+{
+ private readonly IServiceScopeFactory _scopeFactory;
+ private readonly ILogger _logger;
+
+ ///
+ /// مدت زمان انقضا (۳۰ دقیقه)
+ ///
+ private static readonly TimeSpan ExpirationTime = TimeSpan.FromMinutes(30);
+
+ ///
+ /// هر ۵ دقیقه چک میشود
+ ///
+ private static readonly TimeSpan CheckInterval = TimeSpan.FromMinutes(5);
+
+ public ExpirePendingOrdersService(
+ IServiceScopeFactory scopeFactory,
+ ILogger logger)
+ {
+ _scopeFactory = scopeFactory;
+ _logger = logger;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ _logger.LogInformation("ExpirePendingOrdersService started — checking every {Interval} min, expiring after {Expiry} min",
+ CheckInterval.TotalMinutes, ExpirationTime.TotalMinutes);
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ await ExpireOldPendingOrders(stoppingToken);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error in ExpirePendingOrdersService");
+ }
+
+ await Task.Delay(CheckInterval, stoppingToken);
+ }
+ }
+
+ private async Task ExpireOldPendingOrders(CancellationToken cancellationToken)
+ {
+ using var scope = _scopeFactory.CreateScope();
+ var context = scope.ServiceProvider.GetRequiredService();
+ var inventoryService = scope.ServiceProvider.GetRequiredService();
+
+ var cutoff = DateTime.UtcNow - ExpirationTime;
+
+ // پیدا کردن سفارشات Pending قدیمی
+ var expiredOrders = await context.DiscountOrders
+ .Include(o => o.OrderDetails)
+ .Where(o => o.PaymentStatus == PaymentStatus.Pending && o.Created < cutoff)
+ .ToListAsync(cancellationToken);
+
+ if (!expiredOrders.Any()) return;
+
+ _logger.LogInformation("Found {Count} expired pending orders to clean up", expiredOrders.Count);
+
+ foreach (var order in expiredOrders)
+ {
+ try
+ {
+ // آزادسازی رزرو موجودی
+ foreach (var detail in order.OrderDetails)
+ {
+ await inventoryService.ReleaseReservationAsync(
+ detail.ProductId,
+ ProductType.DiscountProduct,
+ detail.Count,
+ order.Id,
+ cancellationToken);
+ }
+
+ // تغییر وضعیت به Reject
+ order.PaymentStatus = PaymentStatus.Reject;
+
+ // آپدیت تراکنش مربوطه
+ if (order.TransactionId.HasValue)
+ {
+ var transaction = await context.Transactions
+ .FirstOrDefaultAsync(t => t.Id == order.TransactionId.Value, cancellationToken);
+ if (transaction != null)
+ {
+ transaction.PaymentStatus = PaymentStatus.Reject;
+ }
+ }
+
+ _logger.LogInformation(
+ "Expired order #{OrderId} (created {Created:u}) — inventory released, status set to Reject",
+ order.Id, order.Created);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to expire order #{OrderId}", order.Id);
+ }
+ }
+
+ await context.SaveChangesAsync(cancellationToken);
+ }
+}
diff --git a/src/CMSMicroservice.Infrastructure/ConfigureServices.cs b/src/CMSMicroservice.Infrastructure/ConfigureServices.cs
index 314e26c..0503b4c 100644
--- a/src/CMSMicroservice.Infrastructure/ConfigureServices.cs
+++ b/src/CMSMicroservice.Infrastructure/ConfigureServices.cs
@@ -4,6 +4,7 @@ using CMSMicroservice.Application.DayaLoanCQ.Services;
using CMSMicroservice.Infrastructure.Persistence;
using CMSMicroservice.Infrastructure.Persistence.Interceptors;
using CMSMicroservice.Infrastructure.BackgroundJobs;
+using CMSMicroservice.Infrastructure.BackgroundServices;
using CMSMicroservice.Infrastructure.Services.Monitoring;
using CMSMicroservice.Infrastructure.Services.Authorization;
using CMSMicroservice.Infrastructure.Configuration;
@@ -119,6 +120,9 @@ public static class ConfigureServices
services.AddScoped(); // Hangfire Job (Scoped for DI)
services.AddScoped(); // Hangfire Job for Chatika activation
+ // Expire pending discount orders after 30 minutes
+ services.AddHostedService();
+
if (configuration.GetValue("UseInMemoryDatabase"))
{
services.AddDbContext(options =>
diff --git a/src/CMSMicroservice.Protobuf/CMSMicroservice.Protobuf.csproj b/src/CMSMicroservice.Protobuf/CMSMicroservice.Protobuf.csproj
index 6dbf384..d888312 100644
--- a/src/CMSMicroservice.Protobuf/CMSMicroservice.Protobuf.csproj
+++ b/src/CMSMicroservice.Protobuf/CMSMicroservice.Protobuf.csproj
@@ -3,7 +3,7 @@
net9.0
enable
enable
- 0.0.178
+ 0.0.179
None
False
False
diff --git a/src/CMSMicroservice.Protobuf/Protos/discountorder.proto b/src/CMSMicroservice.Protobuf/Protos/discountorder.proto
index 5e8e362..8de7f08 100644
--- a/src/CMSMicroservice.Protobuf/Protos/discountorder.proto
+++ b/src/CMSMicroservice.Protobuf/Protos/discountorder.proto
@@ -137,6 +137,7 @@ message GetOrderByIdResponse
repeated OrderItemDto items = 14;
google.protobuf.Timestamp created = 15;
google.protobuf.Timestamp last_modified = 16;
+ PaymentStatus payment_status = 17;
}
message AddressInfo
@@ -191,6 +192,7 @@ message OrderSummaryDto
google.protobuf.StringValue tracking_code = 8;
int32 items_count = 9;
google.protobuf.Timestamp created = 10;
+ PaymentStatus payment_status = 11;
}
// ===== Admin: Get All Discount Orders =====
diff --git a/src/CMSMicroservice.WebApi/Services/DiscountOrderService.cs b/src/CMSMicroservice.WebApi/Services/DiscountOrderService.cs
index 7d8dbf2..e6e44ce 100644
--- a/src/CMSMicroservice.WebApi/Services/DiscountOrderService.cs
+++ b/src/CMSMicroservice.WebApi/Services/DiscountOrderService.cs
@@ -92,6 +92,7 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB
DiscountBalanceUsed = result.DiscountBalanceUsed,
GatewayAmount = result.GatewayAmountPaid,
PaymentCompleted = result.PaymentStatus == DomainEnums.PaymentStatus.Success,
+ PaymentStatus = MapPaymentStatus(result.PaymentStatus),
DeliveryStatus = (DeliveryStatus)(int)result.DeliveryStatus,
Created = Timestamp.FromDateTime(DateTime.SpecifyKind(result.Created, DateTimeKind.Utc)),
};
@@ -151,6 +152,7 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB
DiscountBalanceUsed = o.DiscountBalanceUsed,
GatewayAmount = o.GatewayAmountPaid,
PaymentCompleted = o.PaymentStatus == DomainEnums.PaymentStatus.Success,
+ PaymentStatus = MapPaymentStatus(o.PaymentStatus),
DeliveryStatus = (DeliveryStatus)(int)o.DeliveryStatus,
ItemsCount = o.ItemsCount,
Created = Timestamp.FromDateTime(DateTime.SpecifyKind(o.Created, DateTimeKind.Utc)),
@@ -171,4 +173,12 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB
{
return await _dispatchRequestToCQRS.Handle(request, context);
}
+
+ private static CMSMicroservice.Protobuf.Protos.DiscountOrder.PaymentStatus MapPaymentStatus(DomainEnums.PaymentStatus status) => status switch
+ {
+ DomainEnums.PaymentStatus.Success => CMSMicroservice.Protobuf.Protos.DiscountOrder.PaymentStatus.PaymentCompleted,
+ DomainEnums.PaymentStatus.Reject => CMSMicroservice.Protobuf.Protos.DiscountOrder.PaymentStatus.PaymentFailed,
+ DomainEnums.PaymentStatus.Pending => CMSMicroservice.Protobuf.Protos.DiscountOrder.PaymentStatus.PaymentPending,
+ _ => CMSMicroservice.Protobuf.Protos.DiscountOrder.PaymentStatus.PaymentPending
+ };
}