Skip to content

B2B purchasing roles, order limits and order approval for company accounts - #995

Merged
KrzysztofPajak merged 31 commits into
developfrom
feature/b2b-order-approval
Oct 28, 2026
Merged

KrzysztofPajak merged 31 commits into
developfrom
feature/b2b-order-approval

Conversation

@KrzysztofPajak

@KrzysztofPajak KrzysztofPajak commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

Type: feature

Stacked on #991 (company sub-accounts), so the base is feature/company-subaccounts. Once #991 is merged, this will be retargeted to develop.

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:

  • Purchasing roles: who buys and who approves.
  • A per-order amount limit: an order above the limit waits for the owner or an approver before the shop processes it.

Solution

Roles and limit

  • A sub-account now has a role, Buyer (default) or Approver, and an optional per-order limit, Customer.OrderApprovalLimit.
  • The company owner sets both in My account → Sub-accounts. The limit is entered in the shopper's culture.
  • The admin customer card shows them as read-only.
  • Shops and companies without a limit behave exactly as before.

When an order needs approval

  • The rule applies only to a Buyer sub-account that has a limit, when the sub-accounts tab is enabled (HideSubAccountsTab = false).
  • The order needs approval when the full order total (GetShoppingCartTotal(...).Total, primary currency) is above the limit.

Placement

  • The order is placed at once, in the new system status Awaiting approval (OrderStatusSystem.AwaitingApproval = 5).
  • An OrderApproval record is created for that order.
  • The owner and the approvers get the OrderApproval.Requested e-mail, with the order number and items.
  • The buyer gets the usual "order placed" e-mail.
  • The store-owner and vendor "order placed" e-mails are held back until approval.

Payment methods

  • Such an order can only use payment methods chosen in Admin → Configuration → Payments → Settings. The new global setting is OrderApprovalSettings.PaymentMethodSystemNames, default Cash on delivery.
  • The payment step lists only those methods.
  • A crafted POST with any other method is refused before any payment is processed.

While the order waits

  • CheckOrderStatusCommand never moves an order out of status 5.
  • Paid processing (ProcessOrderPaidCommand: OrderPaidEvent and 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

  • The owner or an approver approves or rejects in My account → Order approvals, optionally with a comment.
  • Approve: the order goes to Pending. The store-owner and vendor e-mails are sent, paid processing runs once if the order is already paid, and the buyer gets OrderApproval.Decided with a link to the order.
  • Reject: the order is cancelled through CancelOrderCommand (stock, discounts, loyalty points and payment are reversed), and the buyer gets Decided with the comment.
  • Withdraw: the buyer can withdraw a pending request, which cancels the order without an e-mail.
  • The order is moved before the decision is saved. If cancelling or changing the status fails, the request stays Pending and can be decided again.

Isolation

  • Every read or write by a customer is filtered by the company (the owner's Id). A foreign or missing id gets the same answer.
  • A buyer cannot decide their own order.
  • Only Pending requests in status 5 can be decided or withdrawn.
  • All state changes are POST with antiforgery.

Back office

  • An order in status 5 cannot get a shipment in the Admin, Store or Vendor panel. The Add shipment button is hidden, and the action refuses.
  • Its status cannot be changed by hand. Moving it out of status 5, or into it, is refused with a message, and status 5 is not offered in the dropdown.
  • The Admin and Store panels can cancel it. The pending request is then closed with the new status Cancelled (OrderApprovalStatus.Cancelled = 40) and no e-mail is sent. A reject or withdraw still records its own status.
  • For an approved order, the "cancel unpaid orders after X days" task counts from the approval, not from the placement.

Upgrade

  • MigrationOrderApproval (2.5) adds the status-5 row, the settings default, both message templates, the OrderApproval indexes (including OrderId) and the resources.
  • It only adds what is missing, so an operator-edited template is left untouched.

Breaking changes

None.

  • Existing customers have no limit, so nothing changes for them.
  • OrderNotificationCommand gains three flags (NotifyCustomer, NotifyStoreOwnerAndVendors, AddOrderPlacedNote), all defaulting to the previous behaviour.
  • New handler constructor parameters are resolved by DI.

Testing

Unit tests (one project at a time):

  • Grand.Business.Checkout.Tests 372/372
  • Grand.Business.Messages.Tests 73/73
  • Grand.Modules.Tests 276/276, including installer seed, migration, template drift guard and resource-key coverage tests
  • Grand.Web.Tests 365/365
  • Grand.Web.Admin.Tests 1730/1730
  • Grand.Web.Store.Tests 187/187
  • Grand.Web.Vendor.Tests 53/53

dotnet build GrandNode.slnx: 0 errors.

E2E (Playwright)

  • Run on a fresh install at 7d9b461, which includes the merge of develop and Fix and finish company sub-accounts (owner checks, Store save, admin owner rules) #991: 116/116 checks pass.
  • It covers all scenarios below, plus the review fixes:
    • no Add shipment button for a status-5 order, and a direct GET or crafted POST is refused in the Admin and Store panels
    • a manual status change into or out of status 5 is refused
    • a back-office cancel restores stock and stores the approval as Cancelled, with no e-mail
    • a buyer promoted to Approver cannot approve their own order
    • with days to cancel unpaid orders = 1, an order approved today survives the expiry task, while a plain unpaid order of the same age is cancelled
  • The Vendor panel shipment guard is covered by unit tests only.
  1. Install a fresh shop. In Admin → Settings → Customer, turn off Hide sub-accounts tab.
  2. Register an owner. In My account → Sub-accounts, add a buyer with the limit 250.5 and an approver.
  3. As the buyer, add products worth more than the limit and go to checkout.
    • Only Cash on delivery is offered.
    • The confirm step says the order will be sent for approval.
    • Place the order. It shows as Awaiting approval. The owner and the approver have a Requested e-mail in the queue; the store owner has none yet.
  4. Place a second order above the limit.
    • As the approver, approve the first: it becomes Pending, and the store-owner e-mail is queued now.
    • As the buyer, withdraw the second: it is cancelled and the stock is back. The first order is untouched.
  5. Place another order. As the owner, reject it with a comment. The order is cancelled, and the buyer sees the comment and gets the Decided e-mail.
  6. Place another order. In admin, mark it paid. It stays Awaiting approval and no "order paid" e-mail is sent. Approve it: it becomes Pending/Processing, and the paid e-mails are sent once.
  7. Refusals:
    • An approver of another company opens the approval URL or posts a decision → not found or refused.
    • The buyer cannot approve their own order.
    • A crafted payment POST with another active method is refused.
  8. A customer without a company and a sub-account without a limit check out exactly as before, with all payment methods. Turning Hide sub-accounts tab back on disables the rule.
  9. Upgrade check: stamp the DB version back to 2.4 and remove the migration's record and the rows it creates, then restart. Everything is re-created. A second run changes nothing.

Self-assessment

What is solid

  • The workflow reuses the shop's own order machinery: status, cancel, notifications and paid processing. Nothing about it is a side channel.
  • A failed step leaves a state that can be retried.
  • Company isolation is enforced in the queries.
  • Customers without a limit are unaffected.

Known limits, left on purpose

  • Concurrent decisions: two decisions at the same moment are narrowed by re-checking the order status. There is no atomic compare-and-set. An approve and a withdraw that land at the very same moment could leave a live order whose stock was already released. Closing this needs a conditional status update on the order.
  • Rare stuck state: if a cancel fails half-way, or the database fails exactly on saving the decision, the request stays Pending while the order already left status 5. This is logged. The order itself can be handled in admin, but there is no admin screen for the approval record.
  • No expiry while waiting: an order in status 5 never expires on its own. The back office can cancel it.
  • Early shipments: an order that got a shipment while awaiting approval, before this PR's guard existed, still cannot be cancelled.
  • No admin screen for approval records: they are visible in the storefront only.
  • Snapshot: the approver sees the order as placed. Later back-office edits to items or totals are not reflected in the request.
  • Paid before approval: an order marked paid while awaiting approval releases its downloads before the decision.
  • Reports: admin reports treat status 5 like any other non-cancelled status.
  • Navigation query: My account pages run one extra count query for company customers when sub-accounts are enabled.
  • Decimal limit: the limit is parsed in the shopper's culture, so 250.5 typed in a de-DE shop means 2505. The storefront's price fields behave the same way.
  • Pages outside a company: the approvals pages open for a customer outside any company when the URL is typed. They show only that customer's own (empty) data.
  • Stale role form: a stale sub-account form posted without a role saves the default role.
  • Redundant check: PlaceOrderCommandHandler keeps its own status-5 skip of paid processing. It is now redundant with the guard in ProcessOrderPaidCommandHandler, and it is kept as defence in depth.

support 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
Base automatically changed from feature/company-subaccounts to develop October 28, 2026 15:00
…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;
}
@KrzysztofPajak
KrzysztofPajak merged commit 7b02a40 into develop Oct 28, 2026
9 checks passed
@KrzysztofPajak
KrzysztofPajak deleted the feature/b2b-order-approval branch October 28, 2026 15:55
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.

1 participant