Skip to content

Let plugins allow, hold or block an order at placement - #1005

Merged
KrzysztofPajak merged 13 commits into
developfrom
feature/imporve-order-customer-limits
Nov 1, 2026
Merged

KrzysztofPajak merged 13 commits into
developfrom
feature/imporve-order-customer-limits

Conversation

@KrzysztofPajak

@KrzysztofPajak KrzysztofPajak commented Oct 31, 2026 •

Copy link
Copy Markdown
Member

Type: feature

Issue

Order approval has one rule, written into the core: a sub-account buyer over an order limit. Any other purchasing policy, such as approval by product category, a monthly budget, a blocked customer or a compliance check on certain goods, needs a core change. Plugins can only reject an order through PlaceOrderDetailsEvent, on the final click, and cannot hold one for approval.

An order held for a customer without a company also had nowhere to go: OrderApprovalRules.CompanyId treats a retail customer as their own company, which sends the request to the only person who may not decide it.

Solution

A contract for plugins. A plugin implements IOrderPlacementRuleProvider and registers it in its StartupApplication, the way a payment provider does. Any number of rules can be active:

  • Any block blocks the order, and it cannot be placed.
  • Otherwise any required approval holds the order for approval. A rule that allows the order never cancels another rule's decision.
  • A rule that throws blocks the order and is logged. The buyer sees a generic text, not the exception. A rule's own timeout counts as a failure too; only an aborted buyer request does not, and it is neither logged as a failure nor turned into a block.
  • A rule returns RequireApproval when a sub-account's company may decide, or RequireStoreApproval for a control the buyer's company must not release.

IOrderPlacementService asks the rules at all four checkout points: the payment list, saving the payment method, the confirm step and placement. The call at placement is authoritative and runs before any payment. When a store skips the payment step for a single method, the rules are asked about that method first; if they refuse it, the step is shown, so the buyer can see why. The existing limit is now the first rule, OrderApprovalLimitRuleProvider, with the same conditions, and it still avoids calculating a total for a retail customer.

Rules receive a CancellationToken, passed down from the request on the checkout steps. Placement does not get the request's token: an order being paid for must finish if the buyer's connection drops.

Who decides. OrderApproval gains Decider, Reasons and CloseId.

  • A sub-account's company decides while the sub-accounts feature is on, as before, provided someone there can be told: the active owner or an active approver with an e-mail address, other than the buyer.
  • The store decides anything else: a customer without a parent account, a company owner, a sub-account with the feature off, a company with nobody to ask, or an order a rule marked with RequireStoreApproval.

The store's decision. The order screen in the Admin and Store panels gets a button opening a popup with approve, reject and a note. Anyone with access to the order sees it, except the person who placed it. The Vendor panel does not get it. On a stale page the popup says the request can no longer be decided. The decision logic moved from the storefront handler to a shared ApplyOrderApprovalDecisionCommand.

One decision wins. IOrderApprovalService.Close replaces Update: a conditional update on the pending status, read back through CloseId, the same pattern as the refresh lease in OidcSessionService. A decision, a withdrawal and a cancellation all close the request this way; a decision and a withdrawal do it before the order moves. Of two at once only one goes on, and the other changes nothing. A failed save leaves the order waiting, and a failed move reopens the request through Reopen.

Telling the store. A new OrderApproval.StaffRequested e-mail goes to a new optional owner account on the store, else to the template's e-mail account. The order's sales employee gets it as well, because that record does not prove the person can open the panel. It never goes to the buyer. These accounts are only told; they are not the only ones who may decide.

The buyer. The reason shows at the confirm step and on the order page, always as plain text. A buyer can withdraw a store-decided request from the order page. A guest gets the decision at the address on the order, and the e-mails use the name given there.

Breaking changes

For an installation: none. New fields read as their defaults when missing, so existing requests stay with the company, and the migration only adds the template.

For code built against unreleased 2.5 develop:

  • IOrderApprovalService.RequiresApproval and OrderApprovalCheckoutState are removed. Use IOrderPlacementService.Evaluate.
  • IOrderApprovalService.Update is removed. Use Close, and Reopen to undo it.
  • ICustomerMessageProviderService gains two methods, and IOrderViewModelService and IStoreViewModelService gain new members, so other implementers must add them.
  • Several constructors change: PlaceOrderCommandHandler, CheckoutController, OrderPlacedOrderApprovalHandler, OrderApprovalService, OrderViewModelService, StoreViewModelService, StoreValidator and the checkout handlers.
  • The OrderApproval.Decided template now greets with OrderApproval.BuyerName. A database that already ran MigrationOrderApproval keeps the old greeting, which is empty for a guest, until the template is edited.

Upgrade path: MigrationOrderApprovalStaffTemplate (2.5) adds the template if it is missing. Resources come from en_250.xml. Rollback: deploying the previous build ignores the new fields. An order already waiting for a store decision then has no button, so staff cancel it.

Testing

Automated: the whole solution builds and CI passes on this branch. Locally: Grand.Business.Checkout.Tests 487, Grand.Business.Messages.Tests 88, Grand.Business.Customers.Tests 138, Grand.Web.Tests 397, Grand.Web.Admin.Tests 1839, Grand.Modules.Tests 344. In the one full-solution run CompanyCreditServiceTests.GetOutstandingAmount_SumsUnpaidCreditOrdersInPrimaryCurrency failed once and then passed four times in a row. It shares the Mongo Order collection with CustomerReportServiceTests, which drops it when the projects run in parallel. No other test failed. OrderApprovalServiceTests and OrderApprovalServiceLiteDbTests run Close and Reopen against both databases.

By hand, on MongoDB with sample data. Add this temporary rule to any plugin and register it with services.AddScoped<IOrderPlacementRuleProvider, TestRule>():

public class TestRule : IOrderPlacementRuleProvider
{
    public string ConfigurationUrl => null;
    public string SystemName => "Test.Rule";
    public string FriendlyName => SystemName;
    public int Priority => 10;
    public IList<string> LimitedToStores => [];
    public IList<string> LimitedToGroups => [];

    public Task<OrderPlacementRuleResult> Evaluate(OrderPlacementContext context,
        CancellationToken cancellationToken = default)
    {
        var email = context.Calculation.Customer.Email ?? "";
        if (email.StartsWith("flag"))
            return Task.FromResult(OrderPlacementRuleResult.RequireApproval("Restricted {{ 1 + 1 }} <b>goods</b>"));
        if (email.StartsWith("store"))
            return Task.FromResult(OrderPlacementRuleResult.RequireStoreApproval("Compliance check"));
        if (email.StartsWith("block"))
            return Task.FromResult(OrderPlacementRuleResult.Block("Blocked customer"));
        if (email.StartsWith("method") && context.PaymentMethodSystemName != null)
            return Task.FromResult(OrderPlacementRuleResult.Block("Not with this payment method"));
        if (email.StartsWith("throw"))
            throw new InvalidOperationException("Secret failure");
        return Task.FromResult(OrderPlacementRuleResult.Allow());
    }
}
  1. In Admin, open Configuration > Stores, edit the store and choose an Owner account. Only active administrators and this store's managers are offered.
  2. Register flag1@x.test and order with Cash on delivery. The confirm step shows the reason literally, with {{ 1 + 1 }} and <b> as text, and the button reads as sending for approval.
  3. The order page shows the status Awaiting approval, the reason and a withdraw button. The queue holds an e-mail to the owner account with a link to the order in the panel.
  4. Open the order in Admin, click Approve or reject, add a note and approve. The order moves to Pending, the buyer sees the note, and the store owner e-mails go out.
  5. Repeat with flag2@x.test and reject. The order is cancelled.
  6. Repeat with flag3@x.test and withdraw from the order page. The order is cancelled.
  7. Repeat with flag4@x.test and approve as a store manager in the Store panel.
  8. Register block@x.test and go to the payment step. The reason shows and no method is offered.
  9. Register throw@x.test and go to the payment step. A generic message shows, without the exception text.
  10. Turn sub-accounts on (HideSubAccountsTab off), give a sub-account whose owner is active and has an e-mail address an order limit, and order over it. The request goes to the company and the Admin order screen shows it read-only, with no button. The owner approves it under My account > Order approvals.
  11. To test the upgrade, set both DataBaseVersion and InstalledVersion in GrandNodeVersion back to 2.4, delete the OrderApproval.StaffRequested template and restart. The template returns and the version is 2.5 again.
  12. Give a sub-account the e-mail store@x.test and place an order. The store decides it even though the buyer has a company.
  13. Repeat step 10 with the company owner deactivated and no approvers. The request goes to the store, and the store's owner account is told.
  14. Open a pending order's page in two tabs and decide it in the first. Click Approve or reject in the second. The popup says the request can no longer be decided.
  15. Turn on SkipPaymentIfOnlyOne with one active payment method and no loyalty points, and check out as method@x.test. The payment step is shown instead of skipped, and choosing the method shows the reason.

I ran steps 1 to 11 on MongoDB with the rule above, compiled into core rather than into a plugin. Steps 1 to 10 ran before the review fixes and were not repeated afterwards. Not run in a browser:

  • Steps 12 to 15. Unit tests cover the same behaviour.
  • A rule loaded from a real plugin. PluginDependencyCycleTests covers the dependency graph.
  • A block that appears only at the confirm step.
  • A guest order.
  • LiteDB.

Known limits

  • A crash in the middle of a decision. If the process dies after the request closes and before the order moves, the request stays closed and the order keeps waiting. Staff can cancel it.
  • A company request that loses its deciders later. After sub-accounts are switched off, or when the company's deciders are deactivated after the order was placed, the request stays with the company. The buyer can withdraw it and staff can cancel it.
  • A faulty plugin stops sales. A rule that throws blocks orders in the stores it applies to. This is deliberate, so a fault cannot switch a purchasing control off. A throw from a rule's constructor, or from its Priority or LimitedTo* getters, also stops the order but can show raw exception text.
  • No allowed payment method. A flagged buyer cannot order if none of the methods in OrderApprovalSettings is active for them.
  • Small things left as they are. After a block at placement the reason shows twice. Panel staff cannot read the decision note. Staff with view-only access see the button and the POST refuses them.

Copilot AI balanced review requested due to automatic review settings October 31, 2026 17:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

details.ApprovalRequired = evaluation.Decision == OrderPlacementDecision.RequireApproval;
details.ApprovalReasons = evaluation.Reasons.ToList();
//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 &&
Comment on lines +146 to +149
catch (Exception e)
{
_logger.LogError(e, "Order approval {Id}: {Step} failed after the decision was saved", id, step);
}
Comment on lines +72 to +77
catch (Exception ex)
{
_logger.LogError(ex, "Order placement rule {SystemName} failed, the order is blocked", rule.SystemName);
return OrderPlacementRuleResult.Block(
_translationService.GetResource("Checkout.OrderPlacement.RuleFailed", context.Language.Id));
}
Comment on lines +43 to +47
catch (Exception ex)
{
logService.LogError(ex, "UpgradeProcess - OrderApprovalStaffTemplate (2.5)");
return false;
}
[DataRow("OrderApproval/Details.cshtml", "reason")]
public void Views_RenderReasonsWithVPre(string view, string value)
{
var path = Path.Combine(RepositoryPaths.Root, "src", "Web", "Grand.Web", "Views", view);
[DataRow("Web/Grand.Web/Views/Order/Details.cshtml", "WithdrawOrderApproval")]
public void DecisionForms_SubmitOnce(string view, string target)
{
var markup = File.ReadAllText(Path.Combine(RepositoryPaths.Root, "src", view));
support added 2 commits October 31, 2026 19:23
A decision was a check followed by a write, so two staff members could both
approve, and a failed save left a moved order whose request could never be
decided again. Closing a request is now atomic and comes before the order
moves; losing the race changes nothing, and a failed move reopens the request.

A company with nobody able to decide kept the order out of everyone's sight,
so the store decides then. A rule can also keep the decision with the store
through RequireStoreApproval: a control the buyer's company must not release.

The single payment method was auto-selected without asking the rules about it,
which ended in a loop the buyer could not leave. The payment step now shows
when the rules refuse that method.

Rules get a CancellationToken and an aborted request is no longer reported as
a failed rule. Placing the order keeps running without the request's token: an
order being paid for must finish if the buyer's connection drops.

Also: the store is always told, not only a sales employee who may have no panel
account; a guest's name comes from the order; repeated reasons are listed once;
the approval popup no longer loads a whole page on a stale order; owner
candidates are filtered by store in the query.
Comment on lines +78 to +84
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 +160 to +164
catch (Exception ex)
{
// a failed e-mail must not stop the others, nor the order
_logger.LogError(ex, "Order approval e-mail to the store failed. Order {OrderId}", order.Id);
}
…ng it

The account is validated when it is saved on the store. A manager moved to
another store, or no longer staff, kept receiving the buyer's name, e-mail and
order of this store until someone changed the field. The message now goes to
the template's e-mail account unless the owner still is an administrator or a
manager of this store.

An administrator tied to another store also got a link to the Store panel, which
refuses that order; they get the Admin panel now.
@KrzysztofPajak
KrzysztofPajak merged commit 8c0e70f into develop Nov 1, 2026
9 checks passed
@KrzysztofPajak
KrzysztofPajak deleted the feature/imporve-order-customer-limits branch November 1, 2026 06:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants