Repository navigation
B2B purchasing roles, order limits and order approval for company accounts - #995
Merged
Merged
Conversation
added 30 commits
October 24, 2026 12:12
Hiding the Subaccounts tab only removed the link: the add, edit and delete actions stayed reachable by URL, and a fresh install hides the tab by default. Every sub-account action now goes through one check, so the setting switches the feature off. The edit command trusted the validator for ownership; it now re-checks it and changes nothing for someone else's, a deleted or a missing account. The edit page answers such an id with a redirect instead of an empty form, the validator gives a missing id and a foreign one the same answer, and the delete endpoint returns the handler's message instead of an empty one. A changed e-mail was stored as typed while sign-in looks it up lowercased, so a sub-account whose owner typed capitals could no longer sign in, and a new password saved together with it failed. The e-mail is stored lowercased and, without usernames, the username follows it. Inactive sub-accounts are marked on the owner's list. Hard-coded messages became resources (also the admin ones used by the next commit).
The Store panel blanked the owner on every customer save, which detached each sub-account a store manager touched. An existing customer now keeps the owner it has; a new one still gets none, and the posted value is never trusted. The admin owner field only checked that the e-mail existed. Sub-accounts stay one level deep now: the owner must be a registered, live customer that is not itself a sub-account, a customer with sub-accounts cannot get an owner, and a customer cannot own itself. The lookup on save uses the same store scope as the validator (it was global, so with per-store identity it could link another store's account), and an owner already linked is kept when posted back unchanged, so legacy data never blocks an unrelated save. The customer form lists an owner's sub-accounts and links a sub-account's owner, read only, on both panels and within each panel's data scope.
Sub-accounts are listed on the storefront and the admin customer form, and the admin checks for them before linking an owner. Without an index each of these scanned the whole customer collection. Fresh installs create it with the other customer indexes, existing ones through a 2.5 migration.
- The Store panel replaces the posted owner before saving, so its validator no longer checks the posted value: the answers told a store manager whether another store's e-mail existed, was a sub-account, was registered or had sub-accounts. - An unchanged owner is kept only while the customer stays in its store; a customer moved to another store has its owner looked up there. - A sub-account e-mail differing only in case is repaired on save even when e-mail changes are not allowed. Older edits stored it as typed, and those rows still could not sign in and failed on a password change. - An owner's own deleted sub-account gets the same answer as a missing or foreign one, as the edit page and the command already give.
…tore saves The Store panel blanked the sales employee, vendor and staff store and reset the groups to Registered on every customer save. A store manager fixing a typo in a name took away the customer's sales employee and price groups, and a colleague's Store manager role. An existing customer now keeps all of these as stored; a new one still gets none of them and Registered only. Posted values are never used either way.
My account saved a changed e-mail or username as typed, while sign-in looks both up lowercased, so a customer who typed capitals could no longer sign in. Both are stored lowercased now, a value differing only in case repairs a row older edits stored as typed, and without usernames the username follows the e-mail as registration sets it.
The Store panel is for a store's registered customers. Its scope also took in any registered account of the store, staff included: administrators, vendors, store managers, sales managers and accounts tied to a vendor or a staff store. These are now outside the panel - left out of the customer list in the query, so paging and totals stay right, and refused when opened by id, which covers every customer tab. GetAllCustomers gains two optional filters for this (excluded groups, and accounts tied to a vendor or a staff store); existing callers are unchanged.
The 2.5 migration creates the OrderApproval indexes and the two approval e-mail templates on existing installations (an operator's template of the same name is kept). The approval e-mail now translates its status and table labels in the buyer's language. Adds every text the feature uses to DefaultLanguage.xml and Upgrade/en_250.xml.
An order that waited for a company approver keeps its placement date, so the cancel-unpaid-orders task cancelled it straight after approval when the wait was longer than the payment window. Skip orders whose approval was decided on or after the cutoff.
A shipment on an order that still awaits approval made the later reject or withdraw fail on the cancel, and a manual status change in or out of Awaiting approval left the approval request and the held-back e-mails and paid processing behind. Refuse both in the shared controllers, hide Add shipment and leave the status out of the change list.
An order whose approval could no longer be decided (no request, the sub-accounts tab hidden, no approvers left) could not be cancelled by the admin or the store owner and kept its stock reserved. Allow the cancel and close a still pending approval request as Cancelled when its order is cancelled; a decision or withdrawal saves its own status after the cancel.
Send the decision e-mail in the buyer's language, keep the translated status token unencoded for the subject, index the approval requests by order, and refuse a decision on one's own order by a buyer later made an approver.
…baccounts # Conflicts: # src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml
…roval # Conflicts: # src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml
…approval # Conflicts: # src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs # src/Tests/Grand.Web.Tests/Commands/Customers/SubAccountEditCommandHandlerTests.cs # src/Tests/Grand.Web.Tests/Features/Customers/GetSubAccountHandlerTests.cs # src/Tests/Grand.Web.Tests/Validators/Customer/SubAccountEditValidatorTests.cs # src/Web/Grand.Web.AdminShared/Models/Customers/CustomerModel.cs # src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs # src/Web/Grand.Web.AdminShared/Views/AdminShared/Customer/Partials/CreateOrUpdate.TabInfo.cshtml # src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml # src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml # src/Web/Grand.Web/Features/Handlers/Customers/GetSubAccountHandler.cs
| return new OrderApprovalSummary { | ||
| SubTotal = _priceFormatter.FormatPrice(includingTax ? order.OrderSubtotalInclTax : order.OrderSubtotalExclTax, | ||
| currency), | ||
| Discount = discount != 0 ? _priceFormatter.FormatPrice(discount, currency) : null, |
|
|
||
| var redirect = result as RedirectToActionResult; | ||
| Assert.IsNotNull(redirect); | ||
| Assert.AreEqual("Edit", redirect.ActionName); |
|
|
||
| var redirect = result as RedirectToActionResult; | ||
| Assert.IsNotNull(redirect); | ||
| Assert.AreEqual("Edit", redirect.ActionName); |
| foreach (var t in MessageTemplateSeed.Build("ea")) | ||
| { | ||
| //templates added after the baseline was taken have nothing to lose | ||
| if (!baseline.ContainsKey(t.Name)) continue; |
Comment on lines
+88
to
+91
| catch (Exception ex) | ||
| { | ||
| _logger.LogError(ex, "Error - order placed attachment file {OrderOrderNumber}", request.Order.OrderNumber); | ||
| } |
Comment on lines
+38
to
+44
| 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
+64
to
+70
| catch (Exception ex) | ||
| { | ||
| // the order is already placed: publishing must complete whatever happens here | ||
| _logger.LogError(ex, | ||
| "Order {OrderNumber} ({OrderId}) awaits approval but its approval request could not be created", | ||
| order.OrderNumber, order.Id); | ||
| } |
Comment on lines
+113
to
+118
| catch (Exception ex) | ||
| { | ||
| // a failed e-mail must not stop the others, nor the order | ||
| _logger.LogError(ex, "Order approval request e-mail failed. Order {OrderId}, approver {ApproverId}", | ||
| order.Id, approver.Id); | ||
| } |
Comment on lines
+79
to
+83
| catch (Exception ex) | ||
| { | ||
| logService.LogError(ex, "UpgradeProcess - OrderApproval (2.5)"); | ||
| return false; | ||
| } |
Comment on lines
+77
to
+82
| catch (Exception e) | ||
| { | ||
| _logger.LogError(e, "Order approval {Id}: the order could not be moved, the request stays pending", | ||
| approval.Id); | ||
| return false; | ||
| } |
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
Stacked on #991 (company sub-accounts), so the base is
feature/company-subaccounts. Once #991 is merged, this will be retargeted todevelop.Issue
A company account (an owner plus its sub-accounts,
Customer.OwnerId) currently has no purchasing control. Any sub-account can place an order of any size on the company's behalf. B2B shops commonly need two things:Solution
Roles and limit
Buyer(default) orApprover, and an optional per-order limit,Customer.OrderApprovalLimit.When an order needs approval
Buyersub-account that has a limit, when the sub-accounts tab is enabled (HideSubAccountsTab = false).GetShoppingCartTotal(...).Total, primary currency) is above the limit.Placement
OrderStatusSystem.AwaitingApproval = 5).OrderApprovalrecord is created for that order.OrderApproval.Requestede-mail, with the order number and items.Payment methods
OrderApprovalSettings.PaymentMethodSystemNames, default Cash on delivery.While the order waits
CheckOrderStatusCommandnever moves an order out of status 5.ProcessOrderPaidCommand:OrderPaidEventand the paid e-mails) is skipped while the order is in status 5. This holds even when an admin marks the order paid or captures it.Decisions
OrderApproval.Decidedwith a link to the order.CancelOrderCommand(stock, discounts, loyalty points and payment are reversed), and the buyer getsDecidedwith the comment.Isolation
Id). A foreign or missing id gets the same answer.Back office
Cancelled(OrderApprovalStatus.Cancelled = 40) and no e-mail is sent. A reject or withdraw still records its own status.Upgrade
MigrationOrderApproval(2.5) adds the status-5 row, the settings default, both message templates, theOrderApprovalindexes (includingOrderId) and the resources.Breaking changes
None.
OrderNotificationCommandgains three flags (NotifyCustomer,NotifyStoreOwnerAndVendors,AddOrderPlacedNote), all defaulting to the previous behaviour.Testing
Unit tests (one project at a time):
dotnet build GrandNode.slnx: 0 errors.E2E (Playwright)
developand Fix and finish company sub-accounts (owner checks, Store save, admin owner rules) #991: 116/116 checks pass.250.5and an approver.Self-assessment
What is solid
Known limits, left on purpose
250.5typed in ade-DEshop means 2505. The storefront's price fields behave the same way.PlaceOrderCommandHandlerkeeps its own status-5 skip of paid processing. It is now redundant with the guard inProcessOrderPaidCommandHandler, and it is kept as defence in depth.