Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .ai/glossary/sales.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ There is no `Cart` entity. A cart is the set of `ShoppingCartItem` rows on a `Cu
| **Order note** | `OrderNote` — operator or system annotation, optionally customer-visible |
| **Order tag** | `OrderTag` — operator-defined label for filtering |
| **Order tax** | `OrderTax` — the tax breakdown as computed at placement |
| **Order placement rule** | `IOrderPlacementRuleProvider` — a provider that allows an order being placed, requires an approval for it, or blocks it |
| **Order approval** | `OrderApproval` — the request to decide an order placed in the `AwaitingApproval` status |
| **Store-decided approval** | an `OrderApproval` with `Decider` set to `Store`: decided in the Admin or Store panel, with no `CompanyOwnerId`. It is the buyer without a company, a rule's `RequireStoreApproval`, or a company with nobody to ask. A company's request is decided on the storefront |

`OrderItem` stores its own prices. Never recompute an order's totals from current product prices — the sold price is the record.

Expand Down
11 changes: 11 additions & 0 deletions .ai/skills/plugin-module.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ Do not use this skill as the primary review for payment correctness, tenant isol
5. Prefer small provider methods that delegate complex behavior to plugin services.
6. Prefer adding a new reference file only when a new extension type appears in the repository.

### Order Placement Rules
A plugin takes part in order placement by implementing `IOrderPlacementRuleProvider` and registering it in its `StartupApplication` (`services.AddScoped<IOrderPlacementRuleProvider, MyRule>()`). `Evaluate` returns `OrderPlacementRuleResult.Allow()`, `.RequireApproval(reason)`, `.RequireStoreApproval(reason)` or `.Block(reason)`. Any number of rules can be active: any block blocks the order, otherwise any required approval requires it, and an `Allow` never cancels another rule's decision. `RequireApproval` lets a sub-account's company decide; use `RequireStoreApproval` for a control the buyer's company must not release. Pass `Evaluate`'s `CancellationToken` to any external call.

1. A rule runs several times per checkout, also on a page view, so it must be fast and have no side effects.
2. `PaymentMethodSystemName` and `GetTotal()` can be null early in checkout, and the rule must decide sensibly then. The call made when the order is placed is the authoritative one.
3. The reason is plain text shown to the buyer and to whoever decides, so nothing confidential. Write it in `context.Language`.
4. A rule that throws blocks the order and is logged, so a faulty rule can stop sales in its stores. Use `LimitedToStores` and `LimitedToGroups` to narrow where it runs.
5. Do not inject `IOrderPlacementService`. Use `context.GetTotal()` for the total: it is calculated once, however many rules ask.

A flagged sub-account order goes to its company while the sub-accounts feature is on. Any other flagged order is decided by the store, on the order screen of the Admin and Store panels.

## Constraints
- Never create a plugin or module outside `src/Plugins` or `src/Modules`.
- Never use a system name that conflicts with an existing plugin or provider.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
using Grand.Business.Core.Commands.Checkout.Orders;
using Grand.Business.Core.Commands.Messages.Common;
using Grand.Business.Core.Interfaces.Checkout.Orders;
using Grand.Business.Core.Interfaces.Common.Stores;
using Grand.Business.Core.Interfaces.Customers;
using Grand.Business.Core.Interfaces.Messages;
using Grand.Domain.Customers;
using Grand.Domain.Orders;
using Grand.Domain.Payments;
using Grand.Mediator;
using Microsoft.Extensions.Logging;

namespace Grand.Business.Checkout.Commands.Handlers.Orders;

public class ApplyOrderApprovalDecisionCommandHandler : IRequestHandler<ApplyOrderApprovalDecisionCommand, bool>
{
private const int MaxCommentLength = 1000;

private readonly ICustomerService _customerService;
private readonly ILogger<ApplyOrderApprovalDecisionCommandHandler> _logger;
private readonly IMediator _mediator;
private readonly ICustomerMessageProviderService _messageProviderService;
private readonly IOrderApprovalService _orderApprovalService;
private readonly IOrderService _orderService;
private readonly IStoreService _storeService;

public ApplyOrderApprovalDecisionCommandHandler(IOrderApprovalService orderApprovalService,
ICustomerService customerService, ICustomerMessageProviderService messageProviderService,
IOrderService orderService, IStoreService storeService, IMediator mediator,
ILogger<ApplyOrderApprovalDecisionCommandHandler> logger)
{
_orderApprovalService = orderApprovalService;
_customerService = customerService;
_messageProviderService = messageProviderService;
_orderService = orderService;
_storeService = storeService;
_mediator = mediator;
_logger = logger;
}

public async Task<bool> Handle(ApplyOrderApprovalDecisionCommand request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request.Approval);
ArgumentNullException.ThrowIfNull(request.DecidedBy);

var approval = request.Approval;
if (approval.Status != OrderApprovalStatus.Pending)
return false;

// nobody decides their own order, also not a buyer made an approver after placing it
if (approval.CustomerId == request.DecidedBy.Id)
return false;

var order = await _orderService.GetOrderById(approval.OrderId);
if (order == null || order.OrderStatusId != (int)OrderStatusSystem.AwaitingApproval)
return false;

var buyer = await _customerService.GetCustomerById(approval.CustomerId);

// the request closes first: of two decisions at once only one goes on, and a failed save leaves the order waiting
if (!await _orderApprovalService.Close(approval,
request.Approve ? OrderApprovalStatus.Approved : OrderApprovalStatus.Rejected, request.DecidedBy.Id,
NormalizeComment(request.Comment)))
return false;

try
{
if (request.Approve)
// the order was placed in the awaiting status; nobody is told about the status change itself
await _mediator.Send(new SetOrderStatusCommand {
Order = order, Os = OrderStatusSystem.Pending, NotifyCustomer = false, NotifyStoreOwner = false
}, cancellationToken);
else
// the customer is not told about the cancellation: the decision e-mail below says it
await _mediator.Send(new CancelOrderCommand { Order = order, NotifyCustomer = false },
cancellationToken);
}
catch (Exception e)
{
_logger.LogError(e, "Order approval {Id}: the order could not be moved, the request goes back to pending",
approval.Id);
await Guard(approval.Id, "reopening the request", () => _orderApprovalService.Reopen(approval));
return false;
}
Comment on lines +78 to +84

// from here the order is an ordinary one: a later failure must not undo or abort the decision
await Guard(order.Id, "order note", () => _orderService.InsertOrderNote(new OrderNote {
Note = $"Order {(request.Approve ? "approved" : "rejected")} by {request.DecidedBy.Email ?? request.DecidedBy.Id}",
DisplayToCustomer = false,
OrderId = order.Id
}));

if (request.Approve)
await ContinueApproved(order, buyer, request, cancellationToken);

await Guard(approval.Id, "decision e-mail", async () =>
{
if (buyer == null)
return;

// the buyer reads it: in the language the order was placed in, not the approver's
var store = await _storeService.GetStoreById(approval.StoreId);
var languageId = string.IsNullOrEmpty(order.CustomerLanguageId)
? request.Language?.Id
: order.CustomerLanguageId;

// a guest has no e-mail of their own: the address they gave is on the order
if (!string.IsNullOrWhiteSpace(buyer.Email))
await _messageProviderService.SendOrderApprovalDecidedMessage(approval, buyer, store, languageId,
request.Host);
else if (!string.IsNullOrWhiteSpace(order.BillingAddress?.Email))
await _messageProviderService.SendOrderApprovalDecidedMessage(approval, buyer, store, languageId,
request.Host, order.BillingAddress.Email);
});

return true;
}

private async Task ContinueApproved(Order order, Customer buyer, ApplyOrderApprovalDecisionCommand request,
CancellationToken cancellationToken)
{
await Guard(order.Id, "order status check",
() => _mediator.Send(new CheckOrderStatusCommand { Order = order }, cancellationToken));

// the customer got the "order placed" e-mail at placement; the store owner and the vendors hear now
await Guard(order.Id, "store owner and vendor e-mails", () => _mediator.Send(new OrderNotificationCommand {
Order = order, Customer = buyer, Host = request.Host, NotifyCustomer = false,
NotifyStoreOwnerAndVendors = true, AddOrderPlacedNote = false
}, cancellationToken));

// the "paid" processing was held back at placement
if (order.PaymentStatusId == PaymentStatus.Paid)
await Guard(order.Id, "paid processing",
() => _mediator.Send(new ProcessOrderPaidCommand { Order = order }, cancellationToken));
}

/// <summary>
/// A failed follow-up step must not abort a decision that is already saved
/// </summary>
private async Task Guard(string id, string step, Func<Task> run)
{
try
{
await run();
}
catch (Exception e)
{
_logger.LogError(e, "Order approval {Id}: {Step} failed after the decision was saved", id, step);
}
Comment on lines +146 to +149
}

private static string NormalizeComment(string comment)
{
if (string.IsNullOrWhiteSpace(comment))
return null;

comment = comment.Trim();
return comment.Length > MaxCommentLength ? comment[..MaxCommentLength] : comment;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using Grand.Business.Core.Commands.Checkout.Orders;
using Grand.Business.Core.Interfaces.Checkout.Orders;
using Grand.Domain.Orders;
using Grand.Mediator;

namespace Grand.Business.Checkout.Commands.Handlers.Orders;

public class DecideStoreOrderApprovalCommandHandler : IRequestHandler<DecideStoreOrderApprovalCommand, bool>
{
private readonly IMediator _mediator;
private readonly IOrderApprovalService _orderApprovalService;

public DecideStoreOrderApprovalCommandHandler(IOrderApprovalService orderApprovalService, IMediator mediator)
{
_orderApprovalService = orderApprovalService;
_mediator = mediator;
}

public async Task<bool> Handle(DecideStoreOrderApprovalCommand request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request.Order);
ArgumentNullException.ThrowIfNull(request.DecidedBy);

// a company's request stays with the company: the store can only cancel the order
var approval = await _orderApprovalService.GetByOrderId(request.Order.Id);
if (approval is not { Decider: OrderApprovalDecider.Store })
return false;

return await _mediator.Send(new ApplyOrderApprovalDecisionCommand {
Approval = approval,
DecidedBy = request.DecidedBy,
Approve = request.Approve,
Comment = request.Comment,
Language = request.Language
}, cancellationToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ public class PlaceOrderCommandHandler : IRequestHandler<PlaceOrderCommand, Place
private readonly ILogger<PlaceOrderCommandHandler> _logger;
private readonly IMediator _mediator;
private readonly IOrderApprovalService _orderApprovalService;
private readonly IOrderPlacementService _orderPlacementService;
private readonly ITranslationService _translationService;
private readonly ICatalogMessageProviderService _catalogMessageProviderService;
private readonly IOrderService _orderService;
Expand Down Expand Up @@ -103,6 +104,7 @@ public PlaceOrderCommandHandler(
ICountryService countryService,
IShoppingCartValidator shoppingCartValidator,
IOrderApprovalService orderApprovalService,
IOrderPlacementService orderPlacementService,
ITranslationService translationService,
ShippingSettings shippingSettings,
ShoppingCartSettings shoppingCartSettings,
Expand Down Expand Up @@ -141,6 +143,7 @@ public PlaceOrderCommandHandler(
_countryService = countryService;
_shoppingCartValidator = shoppingCartValidator;
_orderApprovalService = orderApprovalService;
_orderPlacementService = orderPlacementService;
_translationService = translationService;
_shippingSettings = shippingSettings;
_shoppingCartSettings = shoppingCartSettings;
Expand Down Expand Up @@ -184,10 +187,23 @@ public async Task<PlaceOrderResult> Handle(PlaceOrderCommand command, Cancellati
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))
//the rules are asked before any payment is taken: this answer is the authoritative one
var evaluation = await _orderPlacementService.Evaluate(details.Cart, details.Context, command.Language,
details.PaymentMethodSystemName, cancellationToken);
if (evaluation.Decision == OrderPlacementDecision.Block)
{
foreach (var reason in evaluation.Reasons)
result.AddError(reason);
return result;
}

//an order that needs an approval is placed awaiting it, with an allowed payment method only
details.ApprovalRequired = evaluation.Decision == OrderPlacementDecision.RequireApproval;
details.ApprovalReasons = evaluation.Reasons.ToList();
details.ApprovalStoreDecides = evaluation.StoreDecides;
//with nothing to pay the checkout skips the payment step and clears the method, so there is nothing to check
if (details.ApprovalRequired && details.OrderTotal != 0 &&
!_orderApprovalService.IsPaymentMethodAllowed(details.PaymentMethodSystemName))
{
result.AddError(_translationService.GetResource("Checkout.OrderApproval.PaymentMethodNotAllowed",
command.Language.Id));
Expand Down Expand Up @@ -233,7 +249,10 @@ await _mediator.Send(new OrderNotificationCommand {
await _mediator.Send(new CheckOrderStatusCommand { Order = result.PlacedOrder }, cancellationToken);

//raise event
await _mediator.Publish(new OrderPlacedEvent(result.PlacedOrder), cancellationToken);
await _mediator.Publish(new OrderPlacedEvent(result.PlacedOrder) {
ApprovalReasons = details.ApprovalReasons.ToList(),
ApprovalStoreDecides = details.ApprovalStoreDecides
}, cancellationToken);

//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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,10 @@ public async Task Handle(OrderCancelledEvent notification, CancellationToken can
var order = notification.Order;
try
{
// a decision or a withdrawal cancels the order first and then saves its own status over this one
// a decision or a withdrawal closes the request before cancelling, so it is not pending here
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);
if (approval is { Status: OrderApprovalStatus.Pending })
await _orderApprovalService.Close(approval, OrderApprovalStatus.Cancelled);
}
catch (Exception ex)
{
Expand Down
Loading
Loading