Repository navigation
Let plugins allow, hold or block an order at placement - #1005
Merged
Merged
Conversation
added 10 commits
October 31, 2026 11:05
| 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)); |
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.CompanyIdtreats 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
IOrderPlacementRuleProviderand registers it in itsStartupApplication, the way a payment provider does. Any number of rules can be active:RequireApprovalwhen a sub-account's company may decide, orRequireStoreApprovalfor a control the buyer's company must not release.IOrderPlacementServiceasks 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.
OrderApprovalgainsDecider,ReasonsandCloseId.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.ClosereplacesUpdate: a conditional update on the pending status, read back throughCloseId, the same pattern as the refresh lease inOidcSessionService. 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 throughReopen.Telling the store. A new
OrderApproval.StaffRequestede-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.RequiresApprovalandOrderApprovalCheckoutStateare removed. UseIOrderPlacementService.Evaluate.IOrderApprovalService.Updateis removed. UseClose, andReopento undo it.ICustomerMessageProviderServicegains two methods, andIOrderViewModelServiceandIStoreViewModelServicegain new members, so other implementers must add them.PlaceOrderCommandHandler,CheckoutController,OrderPlacedOrderApprovalHandler,OrderApprovalService,OrderViewModelService,StoreViewModelService,StoreValidatorand the checkout handlers.OrderApproval.Decidedtemplate now greets withOrderApproval.BuyerName. A database that already ranMigrationOrderApprovalkeeps 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 fromen_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.Tests487,Grand.Business.Messages.Tests88,Grand.Business.Customers.Tests138,Grand.Web.Tests397,Grand.Web.Admin.Tests1839,Grand.Modules.Tests344. In the one full-solution runCompanyCreditServiceTests.GetOutstandingAmount_SumsUnpaidCreditOrdersInPrimaryCurrencyfailed once and then passed four times in a row. It shares the MongoOrdercollection withCustomerReportServiceTests, which drops it when the projects run in parallel. No other test failed.OrderApprovalServiceTestsandOrderApprovalServiceLiteDbTestsrunCloseandReopenagainst both databases.By hand, on MongoDB with sample data. Add this temporary rule to any plugin and register it with
services.AddScoped<IOrderPlacementRuleProvider, TestRule>():flag1@x.testand 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.flag2@x.testand reject. The order is cancelled.flag3@x.testand withdraw from the order page. The order is cancelled.flag4@x.testand approve as a store manager in the Store panel.block@x.testand go to the payment step. The reason shows and no method is offered.throw@x.testand go to the payment step. A generic message shows, without the exception text.HideSubAccountsTaboff), 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.DataBaseVersionandInstalledVersioninGrandNodeVersionback to2.4, delete theOrderApproval.StaffRequestedtemplate and restart. The template returns and the version is 2.5 again.store@x.testand place an order. The store decides it even though the buyer has a company.SkipPaymentIfOnlyOnewith one active payment method and no loyalty points, and check out asmethod@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:
PluginDependencyCycleTestscovers the dependency graph.Known limits
PriorityorLimitedTo*getters, also stops the order but can show raw exception text.OrderApprovalSettingsis active for them.