Setting up a partial return of an order in 1C-Bitrix

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
    1177
  • 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

Configuring Partial Order Returns in 1C-Bitrix

A partial return is a return of one or more line items from an order rather than the entire order. A common scenario: the customer ordered 5 products, one arrived defective, and they only want to return that item. Or some items simply did not fit. Bitrix's sale module supports partial returns at the API level, but the personal account interface and the refund amount calculation logic require configuration.

Calculating the partial refund amount

This is the most non-trivial part. An order may contain an order-level discount, coupons, shipping conditions, and multiple VAT rates. A partial return requires recalculating the amount taking all of these factors into account.

Approaches:

Approach 1: Proportional — return a proportion of the total amount. Simple, but may produce penny-level discrepancies due to rounding.

Approach 2: By the actual line item price — use $basketItem->getFinalPrice() × quantity. This is the final price after all line-item discounts. Recommended.

Approach 3: By original price before discounts — rarely used; only when the return policy requires it.

namespace Local\Returns;

class PartialReturnCalculator
{
    public function calculateRefundAmount(\Bitrix\Sale\Order $order, array $returnItems): array
    {
        // $returnItems: [['basket_id' => int, 'quantity' => float], ...]

        $refundItems  = [];
        $totalRefund  = 0.0;
        $basket       = $order->getBasket();

        foreach ($returnItems as $item) {
            $basketItem = $basket->getItemById($item['basket_id']);
            if (!$basketItem) continue;

            $qty           = min((float)$item['quantity'], $basketItem->getQuantity());
            $pricePerUnit  = $basketItem->getFinalPrice(); // price including discounts
            $lineTotal     = round($pricePerUnit * $qty, 2);

            $refundItems[] = [
                'basket_id'    => $item['basket_id'],
                'name'         => $basketItem->getField('NAME'),
                'quantity'     => $qty,
                'price'        => $pricePerUnit,
                'line_total'   => $lineTotal,
                'vat_rate'     => $basketItem->getField('VAT_RATE') ?? 0,
            ];

            $totalRefund += $lineTotal;
        }

        // Recalculate shipping for partial returns
        $shippingRefund = $this->calculateShippingRefund($order, $returnItems, $totalRefund);

        return [
            'items'          => $refundItems,
            'items_total'    => $totalRefund,
            'shipping_refund'=> $shippingRefund,
            'total'          => round($totalRefund + $shippingRefund, 2),
        ];
    }

    private function calculateShippingRefund(
        \Bitrix\Sale\Order $order,
        array $returnItems,
        float $returnItemsTotal
    ): float {
        // If the entire order is being returned — refund shipping in full
        $totalOrderItems = 0;
        $returnBasketIds = array_column($returnItems, 'basket_id');

        foreach ($order->getBasket() as $item) {
            $totalOrderItems++;
        }

        if (count($returnBasketIds) === $totalOrderItems) {
            $shipment = $order->getShipmentCollection()->getSystemShipment();
            return $shipment ? (float)$shipment->getDeliveryPrice() : 0.0;
        }

        // Otherwise — shipping is not refunded (depends on store policy)
        return 0.0;
    }
}

Creating a partial return via Sale API

class PartialReturnManager
{
    public function create(
        int    $orderId,
        array  $returnItems,
        string $reason = '',
        int    $userId = 0
    ): int {
        \Bitrix\Main\Loader::includeModule('sale');

        $order = \Bitrix\Sale\Order::load($orderId);
        if (!$order) throw new \RuntimeException("Order not found: {$orderId}");

        if ($userId && $order->getUserId() !== $userId) {
            throw new \RuntimeException("Access denied to order {$orderId}");
        }

        // Calculate amounts
        $calculator  = new PartialReturnCalculator();
        $refundData  = $calculator->calculateRefundAmount($order, $returnItems);

        // Create the return object
        $orderReturn = \Bitrix\Sale\OrderReturn::create($order);
        $orderReturn->setField('STATUS_ID',    'WAIT');
        $orderReturn->setField('TYPE',         'MONEY');
        $orderReturn->setField('REASON',       $reason ?: 'Partial return');
        $orderReturn->setField('REFUND_AMOUNT', $refundData['total']);
        $orderReturn->setField('COMMENT',      $this->buildComment($refundData));

        // Add return line items
        foreach ($refundData['items'] as $item) {
            $basketItem = $order->getBasket()->getItemById($item['basket_id']);
            if (!$basketItem) continue;

            $returnItem = $orderReturn->getReturn()->createItem($basketItem);
            $returnItem->setField('QUANTITY', $item['quantity']);
            $returnItem->setField('REASON',   $reason);
        }

        $result = $orderReturn->save();

        if (!$result->isSuccess()) {
            throw new \RuntimeException(
                'Partial return failed: ' . implode('; ', $result->getErrorMessages())
            );
        }

        // Update order status if needed
        $this->updateOrderAfterPartialReturn($order, $returnItems);

        return $orderReturn->getId();
    }

    private function updateOrderAfterPartialReturn(
        \Bitrix\Sale\Order $order,
        array $returnItems
    ): void {
        $returnBasketIds   = array_column($returnItems, 'basket_id');
        $totalBasketItems  = count([...$order->getBasket()]);

        // If the last item is being returned — mark the order as partially returned
        if (count($returnBasketIds) < $totalBasketItems) {
            // Add a custom "Partially Returned" status
            // via USER_DESCRIPTION field or a custom status
        }
    }

    private function buildComment(array $refundData): string
    {
        $lines = ['Partial return:'];

        foreach ($refundData['items'] as $item) {
            $lines[] = sprintf(
                '- %s × %s = %s',
                $item['name'],
                $item['quantity'],
                number_format($item['line_total'], 2)
            );
        }

        if ($refundData['shipping_refund'] > 0) {
            $lines[] = sprintf('- Shipping: %s', number_format($refundData['shipping_refund'], 2));
        }

        $lines[] = sprintf('Total refund: %s', number_format($refundData['total'], 2));

        return implode("\n", $lines);
    }
}

Partial refund through the payment system

Most payment systems (YooKassa, Tinkoff) support partial refunds through a dedicated API method. Example for YooKassa:

class YooKassaPartialRefund
{
    public function refund(\Bitrix\Sale\Payment $payment, float $amount, array $items): bool
    {
        $paymentId = $payment->getField('PS_INVOICE_ID'); // YooKassa payment ID

        $receipt = $this->buildReceipt($items); // fiscal receipt for the tax authority

        $response = $this->yukassaClient->createRefund([
            'payment_id' => $paymentId,
            'amount' => [
                'value'    => number_format($amount, 2, '.', ''),
                'currency' => 'RUB',
            ],
            'description' => 'Partial refund for order #' . $payment->getOrderId(),
            'receipt'     => $receipt,
        ]);

        return isset($response['id']) && $response['status'] !== 'canceled';
    }

    private function buildReceipt(array $items): array
    {
        $receiptItems = [];

        foreach ($items as $item) {
            $receiptItems[] = [
                'description' => $item['name'],
                'quantity'    => $item['quantity'],
                'amount'      => [
                    'value'    => number_format($item['price'], 2, '.', ''),
                    'currency' => 'RUB',
                ],
                'vat_code'    => $this->vatRateToCode((float)$item['vat_rate']),
                'payment_mode'    => 'full_payment',
                'payment_subject' => 'commodity',
            ];
        }

        return [
            'customer' => ['email' => $this->customerEmail],
            'items'    => $receiptItems,
        ];
    }
}

A key point: for partial refunds, a correction receipt must be submitted to the tax authority via the fiscal data operator (FDO). YooKassa handles this automatically if receipt is included in the refund request.

Scope of work

  • Refund amount calculator accounting for discounts, quantities, and VAT
  • PartialReturnManager: return creation via Sale ORM
  • Item selection interface in the personal account
  • Integration with payment systems for partial refund
  • Fiscal correction receipt generation for the tax authority
  • Shipping refund logic under specific conditions

Timeline: basic partial return mechanics — 1–2 weeks. Full version with fiscal receipts and multiple payment system integrations — 3–5 weeks.