Skip to main content
Version: Latest
Technical ManualLooking for step-by-step instructions? In the User Manual: B2B Accounts

Accounts B2B

FeatureCrestApps.OrchardCore.Accounts.B2B (Accounts B2B)
CategoryCommerce
Depends onAccounts with Account Members and Account Locations, Commerce, Orders (CrestApps.OrchardCore.Orders), Transactions
Seams it fillsICheckoutTenderProvider, ICheckoutHandler, ICheckoutPaymentGate (Checkout), IOrderEventHandler (Orders), IBuyerPriceProvider (Products), ICartActionProvider (Carts)

The Accounts B2B feature sells to businesses. The business is an account, the same Account content item the CRM and Field Service use; the feature adds B2BAccountPart to it. An account that buys as a business has buyers, some of them admins (the account's members), the places it ships to (its locations), the terms it pays on, a price list, limits and a monthly budget that send orders for approval, and a punchout key for its procurement system. The admin screens are described in the User Manual.

Model​

The feature's migration defines B2BAccountPart and adds it to the Account type at position 10. Another account type can be given the part by hand.

B2BAccountPart
EnabledWhether the account buys as a business. While it is off, its buyers buy for themselves.
TaxId, BillingEmailThe tax number prints on its invoices, and reaches checkout tax: B2BCheckoutTaxProfileHandler taxes a buyer who buys for the account as a business (CustomerTaxType.B2B), with this number as its tax and VAT number. A sale at a register is not taxed as the cashier's account. The exemption and certificates on the buyer's customer record still apply.
OrderLimitsBuyerOrderLimit: UserId, Limit (store currency), for the buyers that have one. A limit for a user who is no longer a member is ignored.
TermsAccountTerms: NetDays, DepositPercent, CreditLimit, RequirePurchaseOrder. OffersTerms is true when NetDays is set.
PriceListIdThe PriceList its buyers pay, or none.
MonthlyBudgetWhat its buyers may order in a calendar month, in the store's currency, or none.
PunchoutAccountPunchout: Enabled, UserId (the buyer the procurement system shops as), KeyHash (SHA-256 of the key, hex), KeyCreatedUtc.

The account's name is its title. B2BAccount reads an account content item together with its part: AccountId (the content item id), Name, IsEnabled (the part is on and the version is published) and the part's values, plus:

  • Buyers: an AccountBuyer for each of the account's members, with their Role (AccountMemberRole.Member or Admin), their OrderLimit and their ContactContentItemId. Buyers are read, never stored: add or remove a buyer by adding or removing the member.
  • Locations: the account's AccountLocations. With Shipping on, AccountLocationsShippingAddressSource (an IShippingAddressSource) offers them on the Shipping step of a buyer's checkout while the account buys as a business, labelled with the account's name; a buyer without a default address of their own starts with the first one.

Indexes​

IndexColumns
B2BAccountIndexContentItemId, Enabled, PriceListId, Published, LatestOne row per version of an account with the part. Backs the b2b: content list term, the price list lookup and the report data sets.

IB2BAccountStore.FindByBuyerAsync(userId) finds the published account a user buys for through IAccountMemberService. B2BAccountService.GetCurrentAsync() returns the signed-in user's enabled account and buyer, cached for the request.

Buyers and contacts​

A buyer is linked to the account's contact with their email by the Account Members feature, when the account is saved (see Account members).

Payment terms and the Business account step​

B2BCheckoutHandler adds a business step (order 3, after contact and shipping) to an account buyer's checkout of an order. B2BCheckoutStepService.SaveAsync(session, purchaseOrderNumber, requestApproval) checks the number (required when RequirePurchaseOrder, 64 characters at most), puts it and the AccountId on the order, and saves the step. An order that already has a purchase order number, such as a quote asked for with one, starts with the step done.

AccountTermsTenderProvider (ICheckoutTenderProvider, key account-terms) puts what the deposit doesn't cover on the account's terms: GrandTotal × (100 − DepositPercent) / 100, capped by what's left of the credit limit, in the store's currency only, never for a register sale. The tender reference is "{accountId}:{sessionId}". The amount becomes a transaction due in NetDays, with source account-terms, owned by the buyer, and with the account as its parent reference (ParentReferenceType Account, ParentReferenceId the account's id). AccountTermsService.ListInvoicesAsync returns an account's invoices from that reference, and GetOwedAsync sums what is outstanding on them, so what an account owes doesn't change when a buyer leaves.

Order.AccountId (indexed as OrderIndex.AccountId, queried with OrderQuery.AccountId) is the account an order was placed for.

The account page​

The feature adds two tabs to an account's page while the account buys as a business, for users with ManageB2BAccounts:

  • Business (shown as Business buying): its terms, credit limit and budget, what it owes, and its buyers with their roles and contacts.
  • Invoices: its terms invoices, newest first, with their status, due date, total and what is still owed, each linked to the transaction.

Its orders are in the Orders tab, from the Orders module. On the editor, the B2B settings are in a Business tab.

Quotes​

Quotes are an Orders feature (see Carts and Orders); an account buyer's quote carries their purchase order number, and the order its account.

Price lists​

PriceList: Name, PercentOff (0 to 100, or none), Currency (the store's when saved) and Entries (PriceListEntry: Sku, UnitPrice), in the PriceLists YesSql collection, indexed by PriceListIndex (Name) through IPriceListStore.

Products.Core has the seam:

public interface IBuyerPriceProvider
{
ValueTask<decimal?> GetUnitPriceAsync(BuyerPriceContext context, CancellationToken cancellationToken = default);
}

public sealed record BuyerPriceContext(ContentItem ContentItem, string VariantId, string Sku, decimal UnitPrice, string Currency, int Quantity);

DefaultPriceResolver asks every provider for a one-time price after converting it to the requested currency, and charges the lowest one below the store's. Volume and role discounts don't come off a buyer's price, and recurring prices and custom amounts are left alone.

B2BPriceProvider answers for the signed-in buyer's account: the entry for the product's or variant's SKU (case-insensitive), converted by IShopperCurrency when the buyer shops in another currency (and skipped when it can't be), or else PercentOff off the price, rounded to the currency. The admin's Prices by SKU box reads a SKU and a price on each line, apart by a comma, semicolon or tab (a decimal comma after a semicolon or tab), at most 5,000 lines; a later line for a SKU replaces an earlier one. Deleting a price list clears it from every account on it.

Approvals and budgets​

B2BApprovalService.GetReasonAsync(account, buyer, total, currency) returns why an order needs approval, or null:

  • an account admin never needs it;
  • a buyer's order over their OrderLimit;
  • an order that takes the account's paid orders this calendar month past MonthlyBudget (orders in other currencies converted with IShopperCurrency).

Order.Approval (Orders.Core OrderApproval) records it: Status (Pending, Approved, Rejected), Reason, Amount and Currency (the total asked about), RequestedUtc, RequestedBy, DecidedUtc, DecidedBy, Note. Covers(total, currency) is true for an approval of at least that total, so an order that grows needs approval again.

  • Asking. The Business account step reports the reason on approval and doesn't save. With requestApproval, RequestAsync sets the approval pending, gives the order an OrderInvoice (so it outlives its checkout, which puts it back to Draft when it ends, and is paid from the buyer's orders), and emails the account's admins.
  • Blocking. B2BApprovalPaymentGate (ICheckoutPaymentGate) refuses payment for an order that needs approval it doesn't have, whatever path the buyer took. QuoteService.CanPay is false for a pending or rejected approval, so no pay link is offered.
  • Deciding. DecideAsync(order, approve, adminName, note) records it and emails the buyer; a rejected order is canceled with OrderCancelReason.Rejected.

Punchout (OCI)​

PunchoutController serves:

Route
GET or POST punchout/oci/{accountId}Takes the key as PASSWORD or key, HOOK_URL and ~TARGET. Refuses (401) an unknown account, one that doesn't buy as a business, a wrong key (compared by hash in fixed time), a HOOK_URL that isn't absolute https (or http on loopback) or carries user info, a punchout user who isn't one of its members, or one in the Administrator role. A refusal is logged without the account's id. Otherwise signs the punchout buyer in, keeps { AccountId, HookUrl, Target } in a data-protected punchout cookie, and opens the home page.
GET punchout/oci/transfer (PunchoutTransfer)Renders the PunchoutTransfer shape: a form of OCI fields that posts itself to the HOOK_URL in the target frame (_top, _parent, _self or _blank).

PunchoutCartActionProvider adds Transfer to your procurement system to the cart while the cookie is there. PunchoutService.ToOciFields(pricing) writes, for each line that can be bought, numbered from 1: NEW_ITEM-DESCRIPTION[n] (40 characters, the full title in NEW_ITEM-LONGTEXT_n:132[] when longer), QUANTITY, UNIT (EA), PRICE (one unit with its add-ons, invariant), PRICEUNIT (1), CURRENCY, VENDORMAT (SKU) and EXT_PRODUCT_ID (content item id). The cart stays; the order is placed in the procurement system.

The store's sign-in cookie is SameSite=Lax, so open the punchout in a top-level window, not a frame of the procurement system. A site with a content security policy has to allow the hook's origin in form-action.

API​

Endpoint
GET account/businessThe buyer's account: role, order limit, terms, what it owes, locations.
GET account/business/approvalsFor an admin: orders waiting for approval (ApiB2BAccountApproval).
POST account/business/approvals/{orderId}For an admin: { approve, note }; 204, or 409 when it isn't waiting.
PUT checkout/{id}/business{ purchaseOrderNumber, requestApproval }; an order that needs approval answers 400 with the reason, or "sent for approval", on approval.
POST/DELETE checkout/{id}/accountPuts the rest on the account's terms, or takes it off.

ApiOrder carries accountId, purchaseOrderNumber, quote and approval.

Admin and permissions​

ScreenPermissionDefault roles
The Business buying, Buyers (order limits), Payment terms and Punchout (OCI) cards on the account editor, and the business buying cards on the account's pageManageB2BAccounts (Manage B2B accounts)Administrator
The Members card, which chooses the buyersManageAccountMembers (Account Members)Administrator
Commerce > B2B Accounts: Orchard Core's content list of the account types with the B2B part, with b2b:true. b2b:false lists the items that don't buy as a business, accounts saved before the part was added included.ListContent and ManageB2BAccountsAdministrator
Commerce > Price lists (PriceListsAdminController, admin route b2b/price-lists), on Orchard Core's AdminListManageB2BAccountsAdministrator
The storefront Business account page (account/business)An account buyer; approvals for its admins

The content list's b2b: term narrows any content list to the accounts that buy as a business (b2b:true) or that have the part turned off (b2b:false).

Recipes and deployment​

Accounts are content items, so the ContentDefinition and Content steps carry them with their B2BAccountPart. Price lists have a step of their own:

{
"name": "PriceLists",
"PriceLists": [
{
"ItemId": "4b7g3ke0q7mnvf3fbzbbyemqtn",
"Name": "Wholesale",
"PercentOff": 10,
"Currency": "USD",
"Entries": [ { "Sku": "HAT-22069", "UnitPrice": 24.5 } ]
}
]
}

A list is matched by ItemId, then by name, so running the step again updates rather than duplicates. The Price Lists deployment step (PriceListsDeploymentStep, under Commerce) exports every list in this shape. With CrestApps Recipes on, both are described by a schema (PriceListsRecipeStepSchema, PriceListsDeploymentStepSchema).

Report Builder data source​

The B2B accounts data source (technical name B2BAccounts) lets the Report Builder and Orchard Core data pipelines read the accounts that buy as a business, their buyers, and the price lists they buy on. It is registered when the Accounts B2B feature and Data Pipelines Core (OrchardCore.DataPipelines.Core) are both enabled. The Report Builder enables Data Pipelines Core. Every data set needs Manage B2B accounts (ManageB2BAccounts). A person without it does not see the data sets, and a report that reads them gets no rows.

Data setDisplay nameRows
B2BAccountsB2B accountsOne per published account with the B2B part. The AccountId of the Orders data set refers to it.
B2BBuyersB2B buyersOne per person who buys for an account.
PriceListsPrice listsOne per price list.
PriceListEntriesPrice list entriesOne per price a price list sets for a SKU.

Accounts are read from their published version through B2BAccountIndex, and price lists newest first by their index rows; the data sets have no main date. A join on Account ID is applied in the database; other filters, and grouping, are applied after the read. The credit limit, the monthly budget and a buyer's order limit are in the store's currency, and the Currency field next to them names it. The punchout key is never exposed.

B2B accounts​

FieldDisplay nameNotes
ItemIdAccount IDThe account's content item id.
NameNameThe account's title.
TaxIdTax or VAT number
BillingEmailBilling email
IsEnabledOnThe part is on and the account is published.
BuyerCountBuyers
AdminCountAdminsThe buyers with the Admin role.
LocationCountLocations
PriceListIdPrice list IDRefers to PriceLists. Empty when the account pays the store's prices.
PunchoutEnabledShops by punchout
OffersTermsBuys on terms
NetDaysNet days
DepositPercentDeposit (%)
RequirePurchaseOrderPurchase order required
CurrencyCurrencyThe store's currency.
CreditLimitCredit limitIn the store's currency. Empty for no limit.
MonthlyBudgetMonthly budgetIn the store's currency. Empty for no budget.
CreatedUtcAddedWhen the account was created.
ModifiedUtcLast changedWhen the account was last saved.

B2B buyers​

Each account gives its buyers, its members, in their order, members without a user included. The buyers are read with their account, so a join on Account ID is applied in the database. A join on User ID is applied after the read.

FieldDisplay nameNotes
AccountIdAccount IDRefers to B2BAccounts.
AccountNameBusiness account
B2BAccountEnabledOrders on account
PositionBuyer numberThe buyer's place in the account, from 1.
UserIdUser IDRefers to the Users data source.
UserNameUser name
EmailEmail
RoleRoleBuyer or Admin.
CurrencyCurrencyThe store's currency.
OrderLimitOrder limitThe most an order the buyer places may come to before an account admin approves it, in the store's currency. Empty for no limit.

Price lists and price list entries​

All prices by SKU are in the price list's currency, which the Currency field names. A join on the price list and a filter on its name are applied in the database.

FieldDisplay nameData setNotes
ItemIdPrice list IDPrice listsIdentifies the price list.
PriceListIdPrice list IDPrice list entriesRefers to PriceLists.
NameNamePrice lists
PriceListNamePrice listPrice list entries
PercentOffPercent offPrice listsThe percentage off every product the list does not price by SKU. Empty when there is none.
CurrencyCurrencyBothThe currency of the list's prices by SKU.
EntryCountPrices by SKUPrice lists
CreatedUtcAddedPrice lists
ModifiedUtcLast changedPrice lists
PositionPrice numberPrice list entriesThe price's place in the list, from 1.
SkuSKUPrice list entries
UnitPriceUnit pricePrice list entriesIn the price list's currency.