Custom Delivery Calculator Development for 1C-Bitrix
Standard delivery services in 1C-Bitrix cover 80% of typical scenarios: fixed price, percentage of order amount, API integration with a carrier. But as soon as non-standard logic appears — simultaneous calculation by weight and volume, zone-based pricing by coordinates, tariff grids with conditional matrices, surcharges for floor delivery — standard tools run out. There are two options: squeeze the logic into the constraints of an existing service (chaotic, unreadable, breaks on updates) or develop a custom delivery handler.
Delivery Handler Architecture in D7
Starting with Bitrix 14.0, all custom delivery services are implemented through a class extending \Bitrix\Sale\Delivery\Services\Base. The handler file is placed in /local/php_interface/include/sale_delivery/:
namespace MyProject\Delivery;
use Bitrix\Sale\Delivery\Services\Base;
use Bitrix\Sale\Delivery\Requests\RequestAbstract;
use Bitrix\Sale\Shipment;
class CustomCalculator extends Base
{
protected static $isCalculatePriceImmediately = true;
protected static $canHasProfiles = true;
public static function getClassTitle(): string
{
return 'Custom Delivery Calculator';
}
public static function getClassDescription(): string
{
return 'Price calculation by weight, zone, and product type';
}
protected function calculateConcrete(Shipment $shipment): \Bitrix\Sale\Delivery\CalculationResult
{
$result = new \Bitrix\Sale\Delivery\CalculationResult();
$price = $this->computePrice($shipment);
if ($price === null) {
$result->addError(new \Bitrix\Main\Error('Unable to calculate delivery'));
return $result;
}
$result->setDeliveryPrice($price);
$result->setPeriodDescription($this->getPeriodText($shipment));
return $result;
}
}
The calculateConcrete method is the key one. All calculation logic lives here. The method receives a Shipment object with full access to the order, products, address, weight, and volume.
Retrieving Shipment Parameters
All data is available inside calculateConcrete:
private function computePrice(Shipment $shipment): ?float
{
$basket = $shipment->getOrder()->getBasket();
$totalWeight = 0;
$totalVolume = 0;
foreach ($basket as $item) {
$props = $item->getPropertyCollection();
$weight = (float)$item->getField('WEIGHT') * $item->getQuantity();
$totalWeight += $weight;
// Volume from product properties (L × W × H in mm)
$l = (float)$props->getItemByFieldValue('CODE', 'LENGTH')?->getValue() / 1000;
$w = (float)$props->getItemByFieldValue('CODE', 'WIDTH')?->getValue() / 1000;
$h = (float)$props->getItemByFieldValue('CODE', 'HEIGHT')?->getValue() / 1000;
$totalVolume += $l * $w * $h * $item->getQuantity();
}
// Dimensional weight coefficient: volumetric weight = volume (m³) × 250
$volumeWeight = $totalVolume * 250;
$billableWeight = max($totalWeight / 1000, $volumeWeight); // in kg
return $this->getPriceByZoneAndWeight(
$this->getDeliveryZone($shipment),
$billableWeight
);
}
The dimensional weight coefficient of 250 is the standard used by most carriers (1 m³ = 250 kg chargeable weight). If a client uses a different coefficient, the parameter is exposed in the delivery service settings.
Zoning: From Simple to Complex
Simple zoning — by city/region from an order property:
private function getDeliveryZone(Shipment $shipment): string
{
$order = $shipment->getOrder();
$props = $order->getPropertyCollection();
$city = $props->getItemByOrderPropertyCode('CITY')?->getValue();
return match(true) {
in_array($city, ['Moscow', 'Saint Petersburg']) => 'zone1',
in_array($city, ['Yekaterinburg', 'Novosibirsk', 'Kazan']) => 'zone2',
default => 'zone3',
};
}
Coordinate-based zoning — relevant for courier delivery within a city (distance-based pricing from a warehouse):
private function getZoneByCoordinates(float $lat, float $lng): string
{
$warehouseLat = 55.7558;
$warehouseLng = 37.6173;
// Haversine formula
$earthRadius = 6371;
$dLat = deg2rad($lat - $warehouseLat);
$dLng = deg2rad($lng - $warehouseLng);
$a = sin($dLat/2) ** 2 + cos(deg2rad($warehouseLat)) * cos(deg2rad($lat)) * sin($dLng/2) ** 2;
$distance = $earthRadius * 2 * atan2(sqrt($a), sqrt(1 - $a));
return match(true) {
$distance <= 10 => 'mkad',
$distance <= 30 => 'mkad_plus30',
$distance <= 50 => 'mkad_plus50',
default => 'region',
};
}
Address coordinates are obtained from Yandex Geocoder or DaData at checkout and stored as an order property.
Tariff Matrix
Tariffs are stored in a database table, not in code — otherwise every tariff change requires a deployment:
private function getPriceByZoneAndWeight(string $zone, float $weight): float
{
// Cache tariffs — avoid hitting the DB on every calculation
$cacheKey = "delivery_tariffs_{$zone}";
if (!isset($this->tariffCache[$cacheKey])) {
$this->tariffCache[$cacheKey] = $this->loadTariffs($zone);
}
$tariffs = $this->tariffCache[$cacheKey];
// Stepped tariff — find the applicable weight range
foreach ($tariffs as $tier) {
if ($weight <= $tier['max_weight']) {
return $tier['base_price'] + ($weight - $tier['min_weight']) * $tier['per_kg'];
}
}
// Oversize — base price plus excess
$lastTier = end($tariffs);
return $lastTier['base_price'] + ($weight - $lastTier['max_weight']) * $lastTier['oversize_per_kg'];
}
A tariff table in the database allows managers to update rates via a simple interface without involving a developer.
Additional Surcharges
Real-world calculators include multiple layers of surcharges:
private function applyAdditionalCharges(float $basePrice, Shipment $shipment): float
{
$price = $basePrice;
// Fragile items surcharge
if ($this->hasFragileItems($shipment)) {
$price += $price * 0.15; // +15%
}
// Floor delivery surcharge
$floor = (int)$this->getOrderProperty($shipment, 'FLOOR');
if ($floor > 1) {
$price += ($floor - 1) * $this->getOption('FLOOR_SURCHARGE', 150);
}
// Cash-on-delivery surcharge
if ($this->isCashOnDelivery($shipment)) {
$codPercent = (float)$this->getOption('COD_PERCENT', 3);
$price += $shipment->getOrder()->getPrice() * ($codPercent / 100);
}
// Discount for large clients (legal entities, B2B group)
if ($this->isB2BClient($shipment->getOrder())) {
$price *= (1 - (float)$this->getOption('B2B_DISCOUNT', 0.1));
}
return round($price, 2);
}
Service Settings in the Admin Interface
Parameters that a manager should be able to change without code access are declared via getHandlerParams():
public static function getHandlerParams(): array
{
return [
'FLOOR_SURCHARGE' => [
'TYPE' => 'NUMBER',
'DEFAULT' => 150,
'TITLE' => 'Floor delivery surcharge',
],
'COD_PERCENT' => [
'TYPE' => 'NUMBER',
'DEFAULT' => 3,
'TITLE' => 'Cash-on-delivery surcharge (%)',
],
'B2B_DISCOUNT' => [
'TYPE' => 'NUMBER',
'DEFAULT' => 0.1,
'TITLE' => 'B2B client discount (fraction, 0.1 = 10%)',
],
'VOLUME_WEIGHT_COEF' => [
'TYPE' => 'NUMBER',
'DEFAULT' => 250,
'TITLE' => 'Dimensional weight coefficient (kg/m³)',
],
];
}
Values are accessed via $this->getOption('PARAM_NAME') — read from the specific service instance settings.
Delivery Service Profiles
A single handler can represent multiple tariff plans via profiles. For example, "Standard (5–7 days)" and "Express (1–2 days)" — one class, two profiles with different coefficients:
protected static $canHasProfiles = true;
public static function getClassTitle(): string
{
return 'Custom Calculator';
}
// Base profile class
class ExpressProfile extends \Bitrix\Sale\Delivery\Services\BaseProfile
{
public function calculateConcrete(Shipment $shipment): CalculationResult
{
$result = parent::calculateConcrete($shipment);
// Multiply by the express delivery coefficient
$result->setDeliveryPrice($result->getDeliveryPrice() * 2.5);
$result->setPeriodFrom(1);
$result->setPeriodTo(2);
return $result;
}
}
Calculation Caching
Delivery calculation is triggered on every cart change — potentially dozens of times per session. Cache the result using a key derived from shipment parameters:
protected function calculateConcrete(Shipment $shipment): CalculationResult
{
$cacheKey = $this->getCacheKey($shipment);
$cache = \Bitrix\Main\Data\Cache::createInstance();
if ($cache->initCache(600, $cacheKey, '/delivery/calc/')) {
$cachedData = $cache->getVars();
$result = new CalculationResult();
$result->setDeliveryPrice($cachedData['price']);
return $result;
}
$result = $this->doCalculate($shipment);
$cache->startDataCache();
$cache->endDataCache(['price' => $result->getDeliveryPrice()]);
return $result;
}
private function getCacheKey(Shipment $shipment): string
{
return md5(serialize([
'zone' => $this->getDeliveryZone($shipment),
'weight' => $this->getTotalWeight($shipment),
'volume' => $this->getTotalVolume($shipment),
'has_fragile' => $this->hasFragileItems($shipment),
'floor' => $this->getOrderProperty($shipment, 'FLOOR'),
]));
}
Cache TTL is 600 seconds: tariffs do not change more frequently, and a cart with the same parameters receives an instant response.
Handler Registration
After creating the class, register it in the system via init.php or a dedicated module:
\Bitrix\Main\EventManager::getInstance()->addEventHandler(
'sale',
'onSaleDeliveryHandlersClassNames',
function(\Bitrix\Main\Event $event) {
$result = $event->getParameters();
$result[] = '\MyProject\Delivery\CustomCalculator';
return new \Bitrix\Main\EventResult(
\Bitrix\Main\EventResult::SUCCESS,
$result
);
}
);
After registration, the class appears in Store → Settings → Delivery Services → Add — create an instance with the required configuration.
Development Timeline
| Calculator Complexity | Timeline |
|---|---|
| Zoning + stepped weight-based tariffs | 2–3 days |
| + Dimensional weight + additional surcharges | 3–5 days |
| + Coordinate-based zoning + tariff management interface | 1–1.5 weeks |
| + Multiple profiles + caching + tests | 1.5–2 weeks |







