Developing a custom 1C-Bitrix shipping calculator

Our company is engaged in the development, support and maintenance of Bitrix and Bitrix24 solutions of any complexity. From simple one-page sites to complex online stores, CRM systems with 1C and telephony integration. The experience of developers is confirmed by certificates from the vendor.
Our competencies:
Development stages
Latest works
  • image_website-b2b-advance_0.png
    B2B ADVANCE company website development
    1175
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Website development for FIXPER company
    811
  • image_bitrix-bitrix-24-1c_development_of_an_online_appointment_booking_widget_for_a_medical_center_594_0.webp
    Development based on Bitrix, Bitrix24, 1C for the company Development of an Online Appointment Booking Widget for a Medical Center
    564
  • image_bitrix-bitrix-24-1c_mirsanbel_458_0.webp
    Development based on 1C Enterprise for MIRSANBEL
    747
  • image_crm_dolbimby_434_0.webp
    Website development on CRM Bitrix24 for DOLBIMBY
    655
  • image_crm_technotorgcomplex_453_0.webp
    Development based on Bitrix24 for the company TECHNOTORGKOMPLEKS
    976

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