Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
1acbf20
Keep storefront sub-account management behind its setting and owner
Oct 24, 2026
bf0c3b4
Validate sub-account owners in the admin and keep them on Store saves
Oct 24, 2026
009675c
Index customers by owner
Oct 24, 2026
31ff502
Close the review findings on sub-account owners
Oct 24, 2026
7fb9a9d
Keep a customer's sales employee, vendor, staff store and groups on S…
Oct 24, 2026
0490365
Store a customer's changed e-mail and username lowercased
Oct 24, 2026
50485a6
Keep the Store panel to the store's customers, without staff accounts
Oct 24, 2026
4ac5e94
Add order approval entity, service and checkout state
Oct 25, 2026
4592d5d
Block over-limit company orders without an approval
Oct 25, 2026
3681c06
Notify approvers and buyers about order approvals
Oct 25, 2026
304c3e1
Let buyers request and approvers decide order approvals
Oct 25, 2026
6e5d947
Show order approvals at the confirm step and in My account
Oct 25, 2026
d6023b7
Let owners set sub-account roles and order limits
Oct 25, 2026
c53096b
Bind the sub-account order limit in the shopper's culture
Oct 25, 2026
06df6aa
Seed order approval templates, indexes and texts for upgrades
Oct 25, 2026
7686025
Place company orders for approval: domain and service
Oct 27, 2026
e032ce6
Place over-limit company orders as awaiting approval
Oct 27, 2026
29fd49f
Keep order placement intact when the approval request fails
Oct 27, 2026
4783894
Approve, reject or withdraw orders awaiting approval
Oct 27, 2026
22cc81b
Move the order before saving the decision on an approval
Oct 27, 2026
95f21b9
Texts, templates and upgrade for orders awaiting approval
Oct 27, 2026
a01b72b
Hold back paid processing while an order awaits approval
Oct 27, 2026
750cd32
Add one Order approved note instead of a second Order placed note on …
Oct 28, 2026
1423ff6
Polish order approval e-mails, resources and guards before the pull r…
Oct 28, 2026
eac2e8c
Count the unpaid-order expiry from the approval
Oct 28, 2026
9f55f7c
Keep orders awaiting approval out of shipments and manual status changes
Oct 28, 2026
3a08648
Let the back office cancel an order awaiting approval
Oct 28, 2026
1f25d16
Smaller order approval fixes from review
Oct 28, 2026
ad4077e
Merge remote-tracking branch 'origin/develop' into feature/company-su…
Oct 28, 2026
7d9b461
Merge branch 'feature/company-subaccounts' into feature/b2b-order-app…
Oct 28, 2026
a428f90
Merge remote-tracking branch 'origin/develop' into feature/b2b-order-…
Oct 28, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ public async Task<bool> Handle(CheckOrderStatusCommand request, CancellationToke
{
ArgumentNullException.ThrowIfNull(request.Order);

//an order waiting for an approval stays as it is until it is decided
if (request.Order.OrderStatusId == (int)OrderStatusSystem.AwaitingApproval)
return true;

if (request.Order.PaymentStatusId == PaymentStatus.Paid && !request.Order.PaidDateUtc.HasValue)
{
//ensure that paid date is set
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,55 +43,59 @@ public async Task Handle(OrderNotificationCommand request, CancellationToken can

try
{
if (request.OriginalCustomerIfImpersonated != null)
if (request.OriginalCustomerIfImpersonated != null && request.AddOrderPlacedNote)
//this order is placed by a store administrator impersonating a customer
await _orderService.InsertOrderNote(new OrderNote {
Note =
$"Order placed by a store owner ('{request.OriginalCustomerIfImpersonated.Email}'. ID = {request.OriginalCustomerIfImpersonated.Id}) impersonating the customer.",
DisplayToCustomer = false,
OrderId = request.Order.Id
});
else
else if (request.AddOrderPlacedNote)
await _orderService.InsertOrderNote(new OrderNote {
Note = "Order placed",
DisplayToCustomer = false,
OrderId = request.Order.Id
});

//send email notifications
await _orderMessageProviderService.SendOrderPlacedStoreOwnerMessage(request.Order, request.Customer,
_languageSettings.DefaultAdminLanguageId, request.Host);
if (request.NotifyStoreOwnerAndVendors)
await _orderMessageProviderService.SendOrderPlacedStoreOwnerMessage(request.Order, request.Customer,
_languageSettings.DefaultAdminLanguageId, request.Host);

string orderPlacedAttachmentFilePath = string.Empty, orderPlacedAttachmentFileName = string.Empty;
var orderPlacedAttachments = new List<string>();

try
{
orderPlacedAttachmentFilePath =
_orderSettings.AttachPdfInvoiceToOrderPlacedEmail && !_orderSettings.AttachPdfInvoiceToBinary
? await _pdfService.PrintOrderToPdf(request.Order, request.Order.CustomerLanguageId)
: null;
orderPlacedAttachmentFileName =
_orderSettings.AttachPdfInvoiceToOrderPlacedEmail && !_orderSettings.AttachPdfInvoiceToBinary
? "order.pdf"
: null;
orderPlacedAttachments = _orderSettings.AttachPdfInvoiceToOrderPlacedEmail &&
_orderSettings.AttachPdfInvoiceToBinary
? [
await _pdfService.SaveOrderToBinary(request.Order, request.Order.CustomerLanguageId)
]
: [];
}
catch (Exception ex)
if (request.NotifyCustomer)
{
_logger.LogError(ex, "Error - order placed attachment file {OrderOrderNumber}", request.Order.OrderNumber);
}
string orderPlacedAttachmentFilePath = string.Empty, orderPlacedAttachmentFileName = string.Empty;
var orderPlacedAttachments = new List<string>();

try
{
orderPlacedAttachmentFilePath =
_orderSettings.AttachPdfInvoiceToOrderPlacedEmail && !_orderSettings.AttachPdfInvoiceToBinary
? await _pdfService.PrintOrderToPdf(request.Order, request.Order.CustomerLanguageId)
: null;
orderPlacedAttachmentFileName =
_orderSettings.AttachPdfInvoiceToOrderPlacedEmail && !_orderSettings.AttachPdfInvoiceToBinary
? "order.pdf"
: null;
orderPlacedAttachments = _orderSettings.AttachPdfInvoiceToOrderPlacedEmail &&
_orderSettings.AttachPdfInvoiceToBinary
? [
await _pdfService.SaveOrderToBinary(request.Order, request.Order.CustomerLanguageId)
]
: [];
}
catch (Exception ex)
{
_logger.LogError(ex, "Error - order placed attachment file {OrderOrderNumber}", request.Order.OrderNumber);
}
Comment on lines +88 to +91

await _orderMessageProviderService.SendOrderPlacedCustomerMessage(request.Order, request.Customer,
request.Order.CustomerLanguageId, orderPlacedAttachmentFilePath, orderPlacedAttachmentFileName,
orderPlacedAttachments, request.Host);
await _orderMessageProviderService.SendOrderPlacedCustomerMessage(request.Order, request.Customer,
request.Order.CustomerLanguageId, orderPlacedAttachmentFilePath, orderPlacedAttachmentFileName,
orderPlacedAttachments, request.Host);
}

if (request.Order.OrderItems.Any(x => !string.IsNullOrEmpty(x.VendorId)))
if (request.NotifyStoreOwnerAndVendors && request.Order.OrderItems.Any(x => !string.IsNullOrEmpty(x.VendorId)))
{
var vendors = await _mediator.Send(new GetVendorsInOrderQuery { Order = request.Order });
foreach (var vendor in vendors)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ public class PlaceOrderCommandHandler : IRequestHandler<PlaceOrderCommand, Place
private readonly ILanguageService _languageService;
private readonly ILogger<PlaceOrderCommandHandler> _logger;
private readonly IMediator _mediator;
private readonly IOrderApprovalService _orderApprovalService;
private readonly ITranslationService _translationService;
private readonly ICatalogMessageProviderService _catalogMessageProviderService;
private readonly IOrderService _orderService;
private readonly OrderSettings _orderSettings;
Expand Down Expand Up @@ -100,6 +102,8 @@ public PlaceOrderCommandHandler(
IAuctionService auctionService,
ICountryService countryService,
IShoppingCartValidator shoppingCartValidator,
IOrderApprovalService orderApprovalService,
ITranslationService translationService,
ShippingSettings shippingSettings,
ShoppingCartSettings shoppingCartSettings,
PaymentSettings paymentSettings,
Expand Down Expand Up @@ -136,6 +140,8 @@ public PlaceOrderCommandHandler(
_auctionService = auctionService;
_countryService = countryService;
_shoppingCartValidator = shoppingCartValidator;
_orderApprovalService = orderApprovalService;
_translationService = translationService;
_shippingSettings = shippingSettings;
_shoppingCartSettings = shoppingCartSettings;
_paymentSettings = paymentSettings;
Expand Down Expand Up @@ -163,6 +169,16 @@ public async Task<PlaceOrderResult> Handle(PlaceOrderCommand command, Cancellati
if (result.Errors.Any())
return result;

//an order over the customer's limit is placed awaiting an approval, with an allowed payment method only
var approval = await _orderApprovalService.RequiresApproval(details.Cart, details.Context);
details.ApprovalRequired = approval.Required;
if (details.ApprovalRequired && !_orderApprovalService.IsPaymentMethodAllowed(details.PaymentMethodSystemName))
{
result.AddError(_translationService.GetResource("Checkout.OrderApproval.PaymentMethodNotAllowed",
command.Language.Id));
return result;
}

#region Payment workflow

var processPayment = await PrepareProcessPayment(details);
Expand Down Expand Up @@ -194,7 +210,8 @@ await _mediator.Send(new OrderNotificationCommand {
Order = result.PlacedOrder,
Customer = details.Customer,
OriginalCustomerIfImpersonated = command.OriginalCustomerIfImpersonated,
Host = command.Host
Host = command.Host,
NotifyStoreOwnerAndVendors = !details.ApprovalRequired
}, cancellationToken);

//check order status
Expand All @@ -203,7 +220,8 @@ await _mediator.Send(new OrderNotificationCommand {
//raise event
await _mediator.Publish(new OrderPlacedEvent(result.PlacedOrder), cancellationToken);

if (result.PlacedOrder.PaymentStatusId == PaymentStatus.Paid)
//an order awaiting approval is processed as paid once it is approved (store owner and vendors hear of it then)
if (!details.ApprovalRequired && result.PlacedOrder.PaymentStatusId == PaymentStatus.Paid)
await _mediator.Send(new ProcessOrderPaidCommand { Order = result.PlacedOrder }, cancellationToken);

#endregion
Expand Down Expand Up @@ -929,7 +947,9 @@ protected virtual Order PrepareOrderHeader(PaymentTransaction paymentTransaction
CurrencyRate = details.CurrencyRate,
Rate = details.Currency.Rate,
AffiliateId = details.AffiliateId,
OrderStatusId = (int)OrderStatusSystem.Pending,
OrderStatusId = details.ApprovalRequired
? (int)OrderStatusSystem.AwaitingApproval
: (int)OrderStatusSystem.Pending,
PaymentMethodSystemName = paymentTransaction.PaymentMethodSystemName,
PaymentOptionAttribute =
details.Customer.GetUserFieldFromEntity<string>(SystemCustomerFieldNames.PaymentOptionAttribute,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ protected virtual async Task ProcessOrderPaid(Order order)
{
ArgumentNullException.ThrowIfNull(order);

//an order waiting for an approval is processed as paid once it is approved, not before
if (order.OrderStatusId == (int)OrderStatusSystem.AwaitingApproval)
return;

//raise event
await _mediator.Publish(new OrderPaidEvent(order));

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using Grand.Business.Core.Events.Checkout.Orders;
using Grand.Business.Core.Interfaces.Checkout.Orders;
using Grand.Domain.Orders;
using Grand.Mediator;
using Microsoft.Extensions.Logging;

namespace Grand.Business.Checkout.Events.Orders;

/// <summary>
/// Closes the approval request of an order cancelled before anyone decided it
/// </summary>
public class OrderCancelledOrderApprovalHandler : INotificationHandler<OrderCancelledEvent>
{
private readonly ILogger<OrderCancelledOrderApprovalHandler> _logger;
private readonly IOrderApprovalService _orderApprovalService;

public OrderCancelledOrderApprovalHandler(IOrderApprovalService orderApprovalService,
ILogger<OrderCancelledOrderApprovalHandler> logger)
{
_orderApprovalService = orderApprovalService;
_logger = logger;
}

public async Task Handle(OrderCancelledEvent notification, CancellationToken cancellationToken)
{
var order = notification.Order;
try
{
// a decision or a withdrawal cancels the order first and then saves its own status over this one
var approval = await _orderApprovalService.GetByOrderId(order.Id);
if (approval is not { Status: OrderApprovalStatus.Pending })
return;

approval.Status = OrderApprovalStatus.Cancelled;
approval.DecidedOnUtc = DateTime.UtcNow;
await _orderApprovalService.Update(approval);
}
catch (Exception ex)
{
// the order is already cancelled: publishing must complete whatever happens here
_logger.LogError(ex,
"Order {OrderNumber} ({OrderId}) was cancelled but its approval request could not be closed",
order.OrderNumber, order.Id);
}
Comment on lines +38 to +44
}
}
Loading
Loading