A closed B2B WooCommerce store is not a regular shop with a login wall added at the end. In the implementation described here, guests cannot browse the catalog, single-product pages are deliberately removed, and the ERP rather than WordPress is the source of truth for availability. Those constraints conflict with several WooCommerce defaults, so they must be designed as system rules rather than assembled from display settings. The four decisions below come from a real wholesale store running on a custom child theme.
Products such as B2BKing, Barn2 Wholesale Pro, and B2B extensions from the WooCommerce marketplace generally start from the same model: a conventional catalog with role-aware pricing and optional access restrictions. That model fits many stores. It becomes awkward when the catalog is private by definition, the product URL is not part of the buying flow, and inventory state arrives from an external system. In that case, the team must own four decisions explicitly.
Catalog Access Belongs at the Request Boundary
A common implementation of “hide prices from guests” filters woocommerce_get_price_html and returns an empty string or a login message. That hides one field, not the catalog. Product images, titles, slugs, stock badges, category URLs, and structured data may still be rendered, indexed, or scraped.
In this store, the primary access rule runs on template_redirect at priority 15. If an unauthenticated visitor requests is_shop(), is_product_category(), is_product_tag(), is_cart(), is_checkout(), or the wishlist, the request redirects to /my-account and exits before the commerce template renders. A woocommerce_get_price_html filter remains as defense in depth, but the redirect is the security boundary.
The Quick View request needs a separate rule because AJAX requests do not pass through the same template path. The unauthenticated Quick View action returns HTTP 401. Once the modal becomes the main product interface, its endpoint is effectively a private product API and must enforce the same access policy as the catalog routes.
Role-based pricing then layers on top through woocommerce_product_get_price and woocommerce_product_get_sale_price. A woocommerce_sale_flash filter suppresses sale badges for the highest wholesale tier because its price list already includes negotiated terms. Presenting a contract price as a temporary promotion would misrepresent the offer.
The Buying Flow Does Not Need a Product Page
WooCommerce treats /product/{slug} as the canonical product interface: it appears in sitemaps, carries schema.org Product markup, and combines the gallery, description, variations, related products, and reviews. That is appropriate for B2C discovery. In this closed wholesale catalog, the buyer’s job is to add several SKUs from a category grid. A separate product page adds another URL, another template, and another interface to secure without improving that workflow.
A second template_redirect callback at priority 20 detects is_product(), calls $wp_query->set_404(), and sends status_header(404) with nocache_headers(). The product URL is intentionally absent rather than redirected to a category. That distinction matters to crawlers, monitoring, and CDN behavior: the resource has not moved; this storefront does not expose it.
The Quick View modal replaces that page. From the category grid, the buyer opens the gallery, description, variation matrix, and role-specific price, then adds the item to the cart without leaving the listing. The authenticated AJAX response supplies the data used to render the modal. The result is one product interface to secure and test, while the buyer-facing URL space is limited to catalog listings and account routes.
This follows the same principle as choosing between a broad plugin stack and an owned integration layer: do not customize an interface before confirming that the workflow needs it. Here, removing the product page reduces the system’s public surface and aligns the storefront with how wholesale buyers actually order.
Product Visibility Has Three Independent Axes
Teams synchronizing WooCommerce with an ERP often treat product visibility as one boolean. WooCommerce exposes at least three related but independent states:
post_statusinwp_posts:publish,draft,private, orpending. It controls whether WordPress includes the product in normal queries._stock_statuspost meta:instock,outofstock, oronbackorder. It represents availability used by purchase controls and stock messages.product_visibilitytaxonomy terms such asexclude-from-catalog,exclude-from-search, andoutofstock. WooCommerce maintains them during its product lifecycle and uses them in catalog queries.
Conflating those states causes avoidable failures. Setting post_status to draft whenever the ERP reports zero stock does not remove historical order totals, because WooCommerce stores line-item values separately. It can, however, degrade admin and reporting interfaces that still resolve the original product to display its link, thumbnail, or SKU. Trashing or deleting the product weakens those references further. Availability and the continued existence of the product record are different concerns.
This implementation therefore keeps relevant product records at post_status = publish and represents current availability through _stock_status. A restriction on woocommerce_product_query at priority 11 adds a meta_query requiring _stock_status = instock for the main catalog query. Out-of-stock products remain resolvable from historical orders but disappear from the buyer-facing grid. A different store may need an archival policy, but stock depletion alone should not define that policy.
There is an important implementation cost. For throughput reasons, this ERP integration updates _stock_status with controlled SQL upserts instead of calling wc_update_product_stock_status() for every SKU. That bypasses WooCommerce lifecycle hooks and can leave product_visibility terms or caches out of sync. The custom catalog query deliberately reads the authoritative meta field, but every other consumer of product visibility must be audited. This is not a generic recommendation to bypass WooCommerce APIs; it is an optimization that transfers consistency, cache invalidation, and regression testing to the project team. The broader rule matches the trade-off in WooCommerce product imports: choose one authoritative representation, then account explicitly for every system that normally maintains the others.
A fourth state is also explicit: the entire store becomes unavailable when the ERP cannot provide trustworthy inventory data. A dedicated template renders a maintenance message instead of serving a catalog whose availability may be stale. Treating an ERP outage as an application state makes its behavior testable and prevents the storefront from silently accepting orders against unknown stock.
Account Onboarding Is an Approval Queue
The default WooCommerce registration flow is optimized for immediate customer access. A closed wholesale channel needs a different lifecycle. An applicant may require verification against a business registry, tax identifier, and credit policy. The role assigned after approval also determines the prices and catalog sections available to that account.
For pending accounts, woocommerce_registration_auth_new_customer prevents automatic authentication. The applicant sees an “application under review” state without receiving a customer session. When an administrator approves the account, a custom action reads the assigned role and dispatches the corresponding WC_Email subclass. The regular and highest wholesale tiers receive different templates, welcome messages, and first-login links.
The important decision is not the email template. It is that role assignment happens during approval, not registration. Automatically authenticating every applicant under a default customer role grants access too early and forces administrators to correct permissions after the fact. By then, the wrong welcome message may already have been sent and the account history no longer reflects the intended approval process.
Removing Product SEO Simplifies the Model
The trade-off is explicit: this store does not use product pages as an organic acquisition channel. There is no canonical product URL strategy, Product schema, Open Graph card for an individual SKU, or product entry in the sitemap. Buyers arrive through a sales relationship, email, or a direct link to /my-account, so exposing product URLs to search engines would add complexity without supporting the sales model.
The return is a smaller system boundary. Product routes return 404 instead of relying on noindex; there are no product canonicals or structured-data templates to maintain; and unauthenticated visitors are redirected before private catalog templates render. The model changes from “a public catalog with private overlays” to “a private catalog.” The access redirect, product-page 404, availability query, and approval queue all enforce that same invariant.
General-purpose B2B plugins cannot decide that product discovery and public URLs have no value for your business. The practical test is simple: if returning 404 for /product/{slug} is unacceptable, the store is not closed in the sense used here, and a conventional B2B plugin model will probably fit better. If product URLs genuinely have no public role, owning these four decisions directly produces a system whose behavior under load, during an ERP outage, and under audit can be reasoned about as one coherent model.