Developing B2B Personal Pricing System for E-commerce
B2B store fundamentally differs from B2C: each counterparty works under individual conditions — own price tier, SKU-level personal discounts, contract prices, payment deferral. Public pricing may not exist. Developing full B2B pricing — most labor-intensive e-commerce task: 10–20 business days depending on ERP integration depth.
Pricing Architecture
System built around concepts:
- Price list — pricing set for customer group
- Customer group — segment: retail, wholesale, dealer, VIP
- Contract price — individual price on specific SKU
- Volume tier — step discount by quantity
- Customer discount — personal percent on top of price list
Application priority (highest to lowest):
- Contract price (personal SKU price)
- Customer group price list
- Volume tier from price list
- Base retail price
Database Schema
CREATE TABLE price_lists (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255),
currency VARCHAR(3) DEFAULT 'RUB',
is_default BOOLEAN DEFAULT FALSE
);
CREATE TABLE price_list_items (
id BIGSERIAL PRIMARY KEY,
price_list_id BIGINT REFERENCES price_lists(id) ON DELETE CASCADE,
product_id BIGINT NOT NULL,
variant_id BIGINT,
price NUMERIC(14,4) NOT NULL,
min_qty INT DEFAULT 1
);
CREATE TABLE customer_groups (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255),
price_list_id BIGINT REFERENCES price_lists(id),
discount_percent NUMERIC(5,2) DEFAULT 0
);
CREATE TABLE customer_prices (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
product_id BIGINT NOT NULL,
variant_id BIGINT,
price NUMERIC(14,4) NOT NULL,
min_qty INT DEFAULT 1,
valid_from DATE,
valid_to DATE
);
Price Resolver
Key class computing final price for customer:
class B2BPriceResolver {
public function resolve(int $productId, int $qty, User $customer): PriceResult {
// 1. Personal contract price
$contractPrice = CustomerPrice::where('user_id', $customer->id)
->where('product_id', $productId)
->where('min_qty', '<=', $qty)
->where(fn($q) => $q->whereNull('valid_from')->orWhere('valid_from', '<=', today()))
->where(fn($q) => $q->whereNull('valid_to')->orWhere('valid_to', '>=', today()))
->orderByDesc('min_qty')
->first();
if ($contractPrice) {
return new PriceResult($contractPrice->price, 'contract');
}
// 2. Group price list with volume tier
$group = $customer->customerGroup;
if ($group?->priceList) {
$tier = PriceListItem::where('price_list_id', $group->price_list_id)
->where('product_id', $productId)
->where('min_qty', '<=', $qty)
->orderByDesc('min_qty')
->first();
if ($tier) {
$price = $tier->price;
if ($group->discount_percent > 0) {
$price *= (1 - $group->discount_percent / 100);
}
return new PriceResult(round($price, 4), 'price_list');
}
}
// 3. Base price
$product = Product::find($productId);
return new PriceResult($product->price, 'retail');
}
}
Price Caching
Resolver called per product on catalog display — 100 items = 100 calls. Without cache — performance disaster.
Strategy: cache entire customer price list on login, invalidate on condition change:
$cacheKey = "b2b_prices:{$user->id}";
$prices = Cache::remember($cacheKey, 3600, function () use ($user) {
return $this->buildUserPriceMap($user);
});
Cache::forget("b2b_prices:{$user->id}");
For massive catalogs (50k+ SKU) build in background, store in Redis Hash.
Price Display
B2B user sees only their prices, no public list. Unauthorized see "Login for prices" or registration prompt. Middleware:
const PriceDisplay = ({ product }: { product: Product }) => {
const { user } = useAuth();
if (!user) return <RequestAccessButton />;
if (!product.b2b_price) return <PriceOnRequest />;
return (
<div>
<span className="text-lg font-bold">{formatPrice(product.b2b_price)}</span>
{product.b2b_source === 'contract' && (
<span className="text-xs text-green-600 ml-2">Contract price</span>
)}
</div>
);
};
Admin Price Management
Admin panel provides:
- Price list CRUD with bulk CSV/XLSX import
- Customer-to-group assignment
- Personal SKU prices with validity dates
- Price preview: "What does customer X see for product Y?"
- Change log with author and timestamp
ERP Integration
For 1C, SAP sync implement API receiver or file-watcher:
Artisan::call('prices:import', [
'--file' => '/var/imports/prices_20240115.csv',
'--price-list' => 3,
'--mode' => 'upsert',
]);
Format: sku,price,min_qty,valid_from,valid_to. Via queue, result report with added/updated/skipped count.
Multi-Currency
If B2B multi-currency, price lists stored in contract currency. Convert by CB rate (cached 1 hour):
$displayPrice = $price * ExchangeRateService::getRate($priceList->currency, 'RUB');
Rate fixed on order creation — rate change between browse and checkout explicitly noted.
Price Negotiation
For enterprise segment implement price request flow: customer requests, manager sets contract price, customer sees in catalog. Statuses: requested → under_review → approved → active. Email notifications per transition.







