B2B is the clearest case for Medusa over a hosted platform, because the four things B2B buyers need are exactly the four things a DTC checkout is not built for: buying on behalf of a company, spending controls, negotiated prices, and paying later.
None ship with Medusa. All are buildable, and the build is the same shape every time.
The company module
Everything starts here. A module owning the company domain:
import { model } from "@medusajs/framework/utils"
export const Company = model.define("company", {
id: model.id().primaryKey(),
name: model.text(),
tax_id: model.text().nullable(),
spending_limit: model.bigNumber().nullable(),
spending_limit_reset: model.enum(["never", "monthly", "yearly"]).default("monthly"),
payment_terms_days: model.number().default(0),
employees: model.hasMany(() => Employee),
})
export const Employee = model.define("company_employee", {
id: model.id().primaryKey(),
// Customer lives in another module, so this is a plain column, not an FK.
customer_id: model.text().index(),
is_admin: model.boolean().default(false),
spending_limit: model.bigNumber().nullable(),
requires_approval_above: model.bigNumber().nullable(),
company: model.belongsTo(() => Company, { mappedBy: "employees" }),
})Then link companies to customer groups so pricing follows the company, and employees to customers so a logged-in buyer resolves to their company.
The mistake to avoid is modelling a company as a customer with metadata. Companies have their own lifecycle, their own admins and their own limits, and the day you need to report on company-level spend, metadata will not answer.
Contract pricing
The easy part. A price list of type override scoped to the company's customer group:
await pricingService.createPriceLists([
{
title: "Acme Ltd contract 2026",
type: "override",
rules: { customer_group_id: [acmeGroupId] },
prices: [
{ amount: 3200, currency_code: "usd", variant_id: "variant_123" },
{ amount: 2900, currency_code: "usd", variant_id: "variant_456" },
],
},
])Acme's buyers see their prices; everyone else sees list prices. No storefront conditionals. Multi-currency and price lists.
Approvals
The capability that most often decides the platform, and the one that must be a workflow step rather than an afterthought:
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
import { COMPANY_MODULE } from "../../modules/company"
type Input = { cart_id: string; customer_id: string; total: number }
export const checkApprovalRequiredStep = createStep(
"check-approval-required",
async ({ cart_id, customer_id, total }: Input, { container }) => {
const companies = container.resolve(COMPANY_MODULE)
const [employee] = await companies.listEmployees({ customer_id })
if (!employee) return new StepResponse({ requires_approval: false })
const threshold = employee.requires_approval_above
const requiresApproval = threshold !== null && total > Number(threshold)
if (requiresApproval) {
const [approval] = await companies.createApprovals([
{ cart_id, employee_id: employee.id, amount: total, status: "pending" },
])
return new StepResponse({ requires_approval: true, approval_id: approval.id }, approval.id)
}
return new StepResponse({ requires_approval: false })
},
// If a later step fails, the pending approval must not survive.
async (approvalId: string | undefined, { container }) => {
if (!approvalId) return
await container.resolve(COMPANY_MODULE).deleteApprovals(approvalId)
}
)Then branch: approval required means the cart is held and the company admin notified; approved means the cart completes through the normal path. Because it is a step with compensation, a failure downstream does not leave orphaned approval records.
Quotes
A quote is a negotiated cart that becomes an order. Model it as its own entity with a status — requested, priced, sent, accepted, expired — with line items that carry a negotiated price alongside the list price so the discount is visible to both sides.
The workflow: buyer requests, sales prices it, buyer accepts, the quote converts to a cart with those prices and completes. Expiry matters — a quote priced against a cost base from four months ago is a margin problem.
Net terms
Paying thirty days later is not a payment method a card processor offers, so it is a manual payment provider: the order completes with payment marked as pending, and an invoice is issued.
The code is the small part. The process is the real work:
- Credit checks before terms are granted, with a limit per company.
- Invoicing, ideally into the accounting system you already use.
- Dunning — reminders at 7, 14 and 30 days past due.
- A block when a company exceeds its limit or has overdue invoices, enforced as a step in the same approval workflow.
That last one is what keeps net terms from becoming an accounts-receivable problem. Enforce it in code, not in a spreadsheet someone checks weekly.
Buyer expectations
Four things B2B buyers assume and DTC storefronts rarely have:
Fast reordering. Order history with one-click reorder, and saved lists. It is the most-used feature in every B2B store we have built.
Bulk entry. A grid or CSV paste keyed by SKU. B2B buyers know their SKUs and do not want to browse.
Purchase order numbers. A field at checkout that appears on the invoice. Trivial to build, and its absence blocks procurement teams entirely.
Tax-exclusive display. With a VAT or tax ID field. Tax configuration.
Effort
| Capability | Typical effort |
|---|---|
| Company accounts and employees | 1–2 weeks |
| Contract pricing | 2–3 days |
| Spending limits and approvals | 1–2 weeks |
| Quotes | 2–3 weeks |
| Net terms and invoicing | 2–3 weeks |
| Bulk order and reordering | 1 week |
Eight to twelve weeks for full B2B on top of a base build. That is the number to compare against an enterprise platform's annual licence — and it is a one-time cost.
Sales-assisted ordering
The capability B2B buyers ask for after the obvious four: a salesperson placing an order on a customer's behalf.
Model it as impersonation with an audit trail, never as a shared login:
export const startAssistedSessionStep = createStep(
"start-assisted-session",
async ({ staff_id, customer_id, reason }: Input, { container }) => {
const audit = container.resolve("audit")
const [session] = await audit.createAssistedSessions([
{ staff_id, customer_id, reason, started_at: new Date() },
])
// Every order created during this session records who really placed it.
return new StepResponse({ session_id: session.id }, session.id)
},
async (sessionId: string | undefined, { container }) => {
if (sessionId) await container.resolve("audit").endAssistedSession(sessionId)
},
)Record the staff member on the resulting order. Six months later, "who placed this and why" is a question someone will ask, and a shared login cannot answer it.
Reporting B2B buyers expect
Four reports that come up in every B2B engagement, and are far easier to build up front than retrofit:
| Report | Used for |
|---|---|
| Spend by company, by period | Budget tracking, contract reviews |
| Spend by employee | Internal cost allocation |
| Order history with PO numbers | Procurement reconciliation |
| Outstanding invoices and ageing | Credit control, on both sides |
Build them as their own tables updated by a subscriber rather than as live aggregates over the order table. A company with three years of orders will not tolerate a dashboard that computes a full-history sum on every page load — and the admin is not the place to discover that.
B2B on Medusa is one of the things we build most often. Ask for a scope.
Frequently asked questions
Does Medusa support B2B out of the box?
Not as prebuilt features. Company accounts, approvals, quotes and net terms are modules and workflows you build, typically eight to twelve weeks on top of a base store. Contract pricing is the exception — price lists scoped to customer groups handle it directly.
How do I implement company accounts in Medusa?
Create a company module owning companies and employees, link employees to customers and companies to customer groups, and resolve the buyer's company at login so pricing, limits and approval rules apply automatically.
How does B2B contract pricing work in Medusa?
Through a price list of type `override` scoped to the company's customer group. Buyers in that group see negotiated prices and everyone else sees list prices, with no conditional logic in the storefront.
How are purchase approvals modelled?
As a workflow step that runs before order placement, comparing the cart total against the employee's threshold and creating a pending approval when it is exceeded. Using a step means a failure later in the workflow removes the approval rather than orphaning it.
How do net payment terms work in Medusa?
Through a manual payment provider that completes the order with payment pending, plus an invoicing and dunning process. Credit limits and overdue blocks should be enforced in the same approval workflow rather than checked manually.
Is Medusa a good fit for B2B commerce?
It is one of the strongest cases for it. B2B requirements are exactly the ones hosted platforms charge enterprise prices to approximate, and building them as modules gives you logic you own outright with no per-year licence.
Building a Marketplace on Medusa: Vendors, Splits and Payouts
Multi-vendor commerce means splitting one customer order into several vendor orders, and moving money to people who are not you. The model and the money mechanics.
Building Subscriptions on Medusa: Billing Cycles, Dunning and Churn
Recurring billing is a scheduling problem with money attached. The data model, the renewal workflow, and the failure handling that determines involuntary churn.
Testing Medusa: What to Test, What to Skip, and How
Commerce code moves money, so untested checkout logic is a liability. A pragmatic test strategy — what earns its keep, and what is theatre.



