Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/Libraries/Nop.Core/Domain/Orders/OrderSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -202,4 +202,24 @@ public partial class OrderSettings : ISettings
/// Gets or sets a value indicating whether to allow customers to cancel orders
/// </summary>
public bool AllowCustomersCancelOrders { get; set; }

/// <summary>
/// Gets or sets a value indicating whether to automatically cancel unpaid orders
/// </summary>
public bool AutoCancelUnpaidOrdersEnabled { get; set; }

/// <summary>
/// Gets or sets the delay (in minutes) after which unpaid orders should be automatically cancelled
/// </summary>
public int AutoCancelUnpaidOrdersDelay { get; set; } = 600;

/// <summary>
/// Gets or sets a comma-separated list of payment method system names to ignore when auto-cancelling unpaid orders
/// </summary>
public string IgnorePaymentMethods { get; set; }

/// <summary>
/// Gets or sets a value indicating whether to restore shopping cart items after automatic order cancellation
/// </summary>
public bool RestoreCartAfterCancellation { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -3359,6 +3359,15 @@ protected virtual async Task InstallScheduleTasksAsync()
Type = "Nop.Services.Gdpr.DeleteInactiveCustomersTask, Nop.Services",
Enabled = false,
StopOnError = false
},
new() {
Name = "Cancel unpaid orders",
//60 minutes
Seconds = 3600,
Type = "Nop.Services.Orders.CancelUnpaidOrdersTask, Nop.Services",
Enabled = true,
LastEnabledUtc = lastEnabledUtc,
StopOnError = false
}
};

Expand Down
169 changes: 169 additions & 0 deletions src/Libraries/Nop.Services/Orders/CancelUnpaidOrdersTask.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
using Nop.Core.Domain.Orders;
using Nop.Core.Domain.Payments;
using Nop.Services.Catalog;
using Nop.Services.Customers;
using Nop.Services.Logging;
using Nop.Services.ScheduleTasks;

namespace Nop.Services.Orders;

/// <summary>
/// Represents a task for automatically cancelling unpaid orders
/// </summary>
public partial class CancelUnpaidOrdersTask : IScheduleTask
{
#region Fields

protected readonly ICustomerService _customerService;
protected readonly ILogger _logger;
protected readonly IOrderProcessingService _orderProcessingService;
protected readonly IOrderService _orderService;
protected readonly IProductService _productService;
protected readonly IShoppingCartService _shoppingCartService;
protected readonly OrderSettings _orderSettings;

#endregion

#region Ctor

public CancelUnpaidOrdersTask(ICustomerService customerService,
ILogger logger,
IOrderProcessingService orderProcessingService,
IOrderService orderService,
IProductService productService,
IShoppingCartService shoppingCartService,
OrderSettings orderSettings)
{
_customerService = customerService;
_logger = logger;
_orderProcessingService = orderProcessingService;
_orderService = orderService;
_productService = productService;
_shoppingCartService = shoppingCartService;
_orderSettings = orderSettings;
}

#endregion

#region Methods

/// <summary>
/// Executes a task
/// </summary>
public virtual async Task ExecuteAsync()
{
// Exit if the feature is disabled
if (!_orderSettings.AutoCancelUnpaidOrdersEnabled)
return;

// Calculate the cutoff time
var delay = _orderSettings.AutoCancelUnpaidOrdersDelay;
if (delay <= 0)
delay = 600; // Default to 600 minutes if invalid

var cutoffTimeUtc = DateTime.UtcNow.AddMinutes(-delay);

// Parse ignored payment methods
var ignoredPaymentMethods = new List<string>();
if (!string.IsNullOrWhiteSpace(_orderSettings.IgnorePaymentMethods))
{
ignoredPaymentMethods = _orderSettings.IgnorePaymentMethods
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(x => x.Trim())
.Where(x => !string.IsNullOrEmpty(x))
.ToList();
}

// Search for orders with pending payment status created before the cutoff time
var pendingOrders = await _orderService.SearchOrdersAsync(
createdToUtc: cutoffTimeUtc,
psIds: new List<int> { (int)PaymentStatus.Pending },
osIds: null, // Don't filter by order status initially
pageIndex: 0,
pageSize: int.MaxValue);

var cancelledCount = 0;
var failedCount = 0;

foreach (var order in pendingOrders)
{
try
{
// Skip already cancelled orders
if (order.OrderStatus == OrderStatus.Cancelled)
continue;

// Skip orders with ignored payment methods
if (ignoredPaymentMethods.Any() &&
!string.IsNullOrWhiteSpace(order.PaymentMethodSystemName) &&
ignoredPaymentMethods.Contains(order.PaymentMethodSystemName, StringComparer.OrdinalIgnoreCase))
continue;

// Cancel the order and notify the customer
await _orderProcessingService.CancelOrderAsync(order, notifyCustomer: true);

// Restore shopping cart if enabled
if (_orderSettings.RestoreCartAfterCancellation)
{
await RestoreShoppingCartAsync(order);
}

cancelledCount++;
}
catch (Exception ex)
{
failedCount++;
await _logger.ErrorAsync($"Error cancelling unpaid order #{order.Id}", ex);
}
}

// Log summary
if (cancelledCount > 0 || failedCount > 0)
{
await _logger.InformationAsync($"Auto-cancel unpaid orders task completed. Cancelled: {cancelledCount}, Failed: {failedCount}");
}
}

/// <summary>
/// Restores shopping cart items from a cancelled order
/// </summary>
/// <param name="order">The cancelled order</param>
/// <returns>A task that represents the asynchronous operation</returns>
protected virtual async Task RestoreShoppingCartAsync(Order order)
{
var customer = await _customerService.GetCustomerByIdAsync(order.CustomerId);
if (customer == null)
return;

var orderItems = await _orderService.GetOrderItemsAsync(order.Id);

foreach (var orderItem in orderItems)
{
try
{
var product = await _productService.GetProductByIdAsync(orderItem.ProductId);
if (product == null || product.Deleted)
continue;

// Add the product back to the shopping cart
await _shoppingCartService.AddToCartAsync(
customer: customer,
product: product,
shoppingCartType: ShoppingCartType.ShoppingCart,
storeId: order.StoreId,
attributesXml: orderItem.AttributesXml,
quantity: orderItem.Quantity,
customerEnteredPrice: orderItem.UnitPriceInclTax,
rentalStartDate: orderItem.RentalStartDateUtc,
rentalEndDate: orderItem.RentalEndDateUtc);
}
catch (Exception ex)
{
// Log the error but continue processing other items
await _logger.WarningAsync($"Failed to restore order item #{orderItem.Id} to shopping cart for customer #{customer.Id}", ex);
}
}
}

#endregion
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using FluentMigrator;
using Nop.Data;
using Nop.Data.Migrations;
using Nop.Web.Framework.Extensions;

namespace Nop.Web.Framework.Migrations.UpgradeTo510;

[NopUpdateMigration("2026-04-11 00:00:01", "5.10", UpdateMigrationType.Localization)]
public class AutoCancelUnpaidOrdersLocalization : MigrationBase
{
/// <summary>Collect the UP migration expressions</summary>
public override void Up()
{
if (!DataSettingsManager.IsDatabaseInstalled())
return;

//add localization resources for auto-cancel unpaid orders feature
this.AddOrUpdateLocaleResource(new Dictionary<string, string>
{
["Admin.Configuration.Settings.Order.AutoCancelUnpaidOrdersEnabled"] = "Enable auto-cancel unpaid orders",
["Admin.Configuration.Settings.Order.AutoCancelUnpaidOrdersEnabled.Hint"] = "Check to enable automatic cancellation of unpaid orders after a specified delay.",

["Admin.Configuration.Settings.Order.AutoCancelUnpaidOrdersDelay"] = "Auto-cancel delay (minutes)",
["Admin.Configuration.Settings.Order.AutoCancelUnpaidOrdersDelay.Hint"] = "Specify the delay in minutes after which unpaid orders should be automatically cancelled. Default is 600 minutes (10 hours).",

["Admin.Configuration.Settings.Order.IgnorePaymentMethods"] = "Ignore payment methods",
["Admin.Configuration.Settings.Order.IgnorePaymentMethods.Hint"] = "Enter comma-separated payment method system names to exclude from auto-cancellation (e.g., \"Payments.CheckMoneyOrder,Payments.Manual\"). Orders using these payment methods will not be automatically cancelled.",

["Admin.Configuration.Settings.Order.RestoreCartAfterCancellation"] = "Restore cart after cancellation",
["Admin.Configuration.Settings.Order.RestoreCartAfterCancellation.Hint"] = "Check to automatically restore shopping cart items when an order is cancelled by the auto-cancel task."
});
}

/// <summary>Collects the DOWN migration expressions</summary>
public override void Down()
{
//nothing
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8425,6 +8425,30 @@
<LocaleResource Name="Admin.Configuration.Settings.Order.AutoUpdateOrderTotalsOnEditingOrder.Hint">
<Value>Check to automatically update order totals on editing an order in admin area. IMPORANT: currently this functionality is in BETA testing status.</Value>
</LocaleResource>
<LocaleResource Name="Admin.Configuration.Settings.Order.AutoCancelUnpaidOrdersEnabled">
<Value>Enable auto-cancel unpaid orders</Value>
</LocaleResource>
<LocaleResource Name="Admin.Configuration.Settings.Order.AutoCancelUnpaidOrdersEnabled.Hint">
<Value>Check to enable automatic cancellation of unpaid orders after a specified delay.</Value>
</LocaleResource>
<LocaleResource Name="Admin.Configuration.Settings.Order.AutoCancelUnpaidOrdersDelay">
<Value>Auto-cancel delay (minutes)</Value>
</LocaleResource>
<LocaleResource Name="Admin.Configuration.Settings.Order.AutoCancelUnpaidOrdersDelay.Hint">
<Value>Specify the delay in minutes after which unpaid orders should be automatically cancelled. Default is 600 minutes (10 hours).</Value>
</LocaleResource>
<LocaleResource Name="Admin.Configuration.Settings.Order.IgnorePaymentMethods">
<Value>Ignore payment methods</Value>
</LocaleResource>
<LocaleResource Name="Admin.Configuration.Settings.Order.IgnorePaymentMethods.Hint">
<Value>Select payment methods to exclude from auto-cancellation. Orders using these payment methods will not be automatically cancelled.</Value>
</LocaleResource>
<LocaleResource Name="Admin.Configuration.Settings.Order.RestoreCartAfterCancellation">
<Value>Restore cart after cancellation</Value>
</LocaleResource>
<LocaleResource Name="Admin.Configuration.Settings.Order.RestoreCartAfterCancellation.Hint">
<Value>Check to automatically restore shopping cart items when an order is cancelled by the auto-cancel task.</Value>
</LocaleResource>
<LocaleResource Name="Admin.Configuration.Settings.Order.BlockTitle.Checkout">
<Value>Checkout</Value>
</LocaleResource>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -939,6 +939,11 @@ public virtual async Task<IActionResult> Order(OrderSettingsModel model)
var orderSettings = await _settingService.LoadSettingAsync<OrderSettings>(storeScope);
orderSettings = model.ToSettings(orderSettings);

//convert selected payment methods to comma-separated string
orderSettings.IgnorePaymentMethods = model.SelectedPaymentMethods != null && model.SelectedPaymentMethods.Any()
? string.Join(",", model.SelectedPaymentMethods)
: string.Empty;

//we do not clear cache after each setting update.
//this behavior can increase performance because cached settings will not be cleared
//and loaded from database after each update
Expand Down Expand Up @@ -968,6 +973,10 @@ public virtual async Task<IActionResult> Order(OrderSettingsModel model)
await _settingService.SaveSettingOverridablePerStoreAsync(orderSettings, x => x.ExportWithProducts, model.ExportWithProducts_OverrideForStore, storeScope, false);
await _settingService.SaveSettingOverridablePerStoreAsync(orderSettings, x => x.AllowAdminsToBuyCallForPriceProducts, model.AllowAdminsToBuyCallForPriceProducts_OverrideForStore, storeScope, false);
await _settingService.SaveSettingOverridablePerStoreAsync(orderSettings, x => x.AllowCustomersCancelOrders, model.AllowCustomersCancelOrders_OverrideForStore, storeScope, false);
await _settingService.SaveSettingOverridablePerStoreAsync(orderSettings, x => x.AutoCancelUnpaidOrdersEnabled, model.AutoCancelUnpaidOrdersEnabled_OverrideForStore, storeScope, false);
await _settingService.SaveSettingOverridablePerStoreAsync(orderSettings, x => x.AutoCancelUnpaidOrdersDelay, model.AutoCancelUnpaidOrdersDelay_OverrideForStore, storeScope, false);
await _settingService.SaveSettingOverridablePerStoreAsync(orderSettings, x => x.IgnorePaymentMethods, model.IgnorePaymentMethods_OverrideForStore, storeScope, false);
await _settingService.SaveSettingOverridablePerStoreAsync(orderSettings, x => x.RestoreCartAfterCancellation, model.RestoreCartAfterCancellation_OverrideForStore, storeScope, false);
await _settingService.SaveSettingOverridablePerStoreAsync(orderSettings, x => x.ShowProductThumbnailInOrderDetailsPage, model.ShowProductThumbnailInOrderDetailsPage_OverrideForStore, storeScope, false);
await _settingService.SaveSettingOverridablePerStoreAsync(orderSettings, x => x.DeleteGiftCardUsageHistory, model.DeleteGiftCardUsageHistory_OverrideForStore, storeScope, false);
await _settingService.SaveSettingAsync(orderSettings, x => x.ActivateGiftCardsAfterCompletingOrder, 0, false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
using Nop.Services.Helpers;
using Nop.Services.Localization;
using Nop.Services.Media;
using Nop.Services.Payments;
using Nop.Services.Stores;
using Nop.Services.Themes;
using Nop.Web.Areas.Admin.Infrastructure.Mapper.Extensions;
Expand Down Expand Up @@ -65,6 +66,7 @@ public partial class SettingModelFactory : ISettingModelFactory
protected readonly IGenericAttributeService _genericAttributeService;
protected readonly ILanguageService _languageService;
protected readonly ILocalizationService _localizationService;
protected readonly IPaymentPluginManager _paymentPluginManager;
protected readonly IPictureService _pictureService;
protected readonly IReturnRequestModelFactory _returnRequestModelFactory;
protected readonly IReviewTypeModelFactory _reviewTypeModelFactory;
Expand Down Expand Up @@ -96,6 +98,7 @@ public SettingModelFactory(AppSettings appSettings,
IGenericAttributeService genericAttributeService,
ILanguageService languageService,
ILocalizationService localizationService,
IPaymentPluginManager paymentPluginManager,
IPictureService pictureService,
IReturnRequestModelFactory returnRequestModelFactory,
ISettingService settingService,
Expand Down Expand Up @@ -123,6 +126,7 @@ public SettingModelFactory(AppSettings appSettings,
_genericAttributeService = genericAttributeService;
_languageService = languageService;
_localizationService = localizationService;
_paymentPluginManager = paymentPluginManager;
_pictureService = pictureService;
_returnRequestModelFactory = returnRequestModelFactory;
_settingService = settingService;
Expand Down Expand Up @@ -1472,6 +1476,28 @@ public virtual async Task<OrderSettingsModel> PrepareOrderSettingsModelAsync(Ord
model.PrimaryStoreCurrencyCode = (await _currencyService.GetCurrencyByIdAsync(_currencySettings.PrimaryStoreCurrencyId))?.CurrencyCode;
model.OrderIdent = await _dataProvider.GetTableIdentAsync<Order>();

//load available payment methods
var paymentMethods = await _paymentPluginManager
.LoadAllPluginsAsync(customer: await _workContext.GetCurrentCustomerAsync());

model.AvailablePaymentMethods = paymentMethods
.Select(pm => new SelectListItem
{
Value = pm.PluginDescriptor.SystemName,
Text = pm.PluginDescriptor.FriendlyName
})
.OrderBy(x => x.Text)
.ToList();

//preselect saved values
if (!string.IsNullOrWhiteSpace(orderSettings.IgnorePaymentMethods))
{
model.SelectedPaymentMethods = orderSettings.IgnorePaymentMethods
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(x => x.Trim())
.ToList();
}

//fill in overridden values
if (storeId > 0)
{
Expand Down
Loading
Loading