Product Exchange Setup on 1C-Bitrix: Logic, Code & Automation
Imagine a customer bought an expensive coffee machine for $800, but a week later realized they need a model with a cappuccino maker. Refund? They want to pay an extra $150 and get the other one. Bitrix has no built-in exchange mechanism — it's a combination of a return and a new order. We embed the logic on top of the sale module using Highload-blocks and custom event handlers. On one project with an electronics catalog of 15,000 SKUs, automation cut request processing time by 70% — managers stopped manually linking returns. The average exchange time dropped from 3 days to 2 hours. Below is a proven architecture we implement on projects of various sizes.
What Typical Problems Arise During Exchanges?
Without a ready solution, developers face three bottlenecks:
- Lost link between the return and the new order: managers manually cross-check numbers, leading to errors in 15% of cases.
- Errors in calculating the surcharge: if the original order discounts are not taken into account, the customer overpays up to 25% of the value.
- Manual statuses: they forget to set the order to 'exchange completed' when goods are received — 30% of requests hang for a week.
We solve this with a single local_sale_exchange table, the ExchangeManager class, and automatic updates via events.
Why Automate Exchanges?
Manual exchange means risk of errors and loss of loyalty. The customer expects transparency: sees the replacement status and surcharge amount in their personal account. Automation eliminates the human factor. For example, on one project after implementation, the number of repeat support requests decreased by 40%. The solution pays for itself in 2–3 months through manager time savings (average 45 minutes per request).
Business Logic of Exchange
Two exchange schemes need to be supported:
- Scheme 1: 1-to-1 exchange — items of equal cost. A return is created for the original item, a new order for the replacement. Surcharge/refund difference = 0.
- Scheme 2: Exchange with surcharge/refund of difference — items of different cost. If the new one is more expensive, the customer pays extra. If cheaper, we refund the difference.
How Is the Exchange Data Structure Organized?
To store the link between the original return and the new order, we create a Highload-block:
class ExchangeTable extends \Bitrix\Main\ORM\Data\DataManager
{
public static function getTableName(): string { return 'local_sale_exchange'; }
public static function getMap(): array
{
return [
new \Bitrix\Main\ORM\Fields\IntegerField('ID', ['primary' => true, 'autocomplete' => true]),
new \Bitrix\Main\ORM\Fields\IntegerField('ORIGINAL_ORDER_ID'),
new \Bitrix\Main\ORM\Fields\IntegerField('RETURN_ID'), // ID of the return request
new \Bitrix\Main\ORM\Fields\IntegerField('NEW_ORDER_ID'), // ID of the new order
new \Bitrix\Main\ORM\Fields\IntegerField('ORIGINAL_BASKET_ID'), // position in the original order
new \Bitrix\Main\ORM\Fields\IntegerField('NEW_PRODUCT_ID'), // new product
new \Bitrix\Main\ORM\Fields\FloatField('ORIGINAL_PRICE'),
new \Bitrix\Main\ORM\Fields\FloatField('NEW_PRICE'),
new \Bitrix\Main\ORM\Fields\FloatField('DIFF_AMOUNT'), // surcharge (+) or refund (-)
new \Bitrix\Main\ORM\Fields\StringField('STATUS'), // pending, paid, completed
new \Bitrix\Main\ORM\Fields\DatetimeField('CREATED_AT'),
];
}
}
How ExchangeManager Creates an Exchange
namespace Local\Returns;
class ExchangeManager
{
public function initiateExchange(array $params): array
{
// $params:
// - original_order_id: int
// - original_basket_id: int (the line item being exchanged)
// - new_product_id: int (replacement product)
// - new_product_props: [] (size, color, etc.)
\Bitrix\Main\Loader::includeModule('sale');
\Bitrix\Main\Loader::includeModule('catalog');
$order = \Bitrix\Sale\Order::load($params['original_order_id']);
if (!$order || $order->getUserId() !== $this->currentUserId) {
throw new \RuntimeException('Order not found');
}
// Get the original basket item
$originalItem = null;
foreach ($order->getBasket() as $item) {
if ($item->getId() === (int)$params['original_basket_id']) {
$originalItem = $item;
break;
}
}
if (!$originalItem) {
throw new \RuntimeException('Basket item not found');
}
$originalPrice = $originalItem->getFinalPrice();
// Price of the new product
$newPrice = $this->getProductPrice($params['new_product_id']);
$diffAmount = $newPrice - $originalPrice;
// Create a return request for the original item
$returnManager = new ReturnManager();
$returnId = $returnManager->createReturn(
$params['original_order_id'],
[['basket_id' => $params['original_basket_id'], 'quantity' => 1, 'reason' => 'exchange']],
'EXCHANGE'
);
// Create a new order for the replacement
$newOrderId = $this->createExchangeOrder(
$order->getUserId(),
$params['new_product_id'],
$params['new_product_props'] ?? [],
$diffAmount,
$order
);
// Save the link
$exchangeId = ExchangeTable::add([
'ORIGINAL_ORDER_ID' => $params['original_order_id'],
'RETURN_ID' => $returnId,
'NEW_ORDER_ID' => $newOrderId,
'ORIGINAL_BASKET_ID'=> $params['original_basket_id'],
'NEW_PRODUCT_ID' => $params['new_product_id'],
'ORIGINAL_PRICE' => $originalPrice,
'NEW_PRICE' => $newPrice,
'DIFF_AMOUNT' => $diffAmount,
'STATUS' => $diffAmount > 0 ? 'pending_payment' : 'pending_ship',
'CREATED_AT' => new \Bitrix\Main\Type\DateTime(),
])->getId();
return [
'exchange_id' => $exchangeId,
'return_id' => $returnId,
'new_order_id' => $newOrderId,
'diff_amount' => $diffAmount,
'needs_payment'=> $diffAmount > 0,
];
}
private function createExchangeOrder(
int $userId,
int $productId,
array $props,
float $diffAmount,
\Bitrix\Sale\Order $originalOrder
): int {
$order = \Bitrix\Sale\Order::create(SITE_ID, $userId);
$order->setPersonTypeId($originalOrder->getPersonTypeId());
// Copy delivery address from original order
$propertyCollection = $order->getPropertyCollection();
foreach ($originalOrder->getPropertyCollection() as $prop) {
$newProp = $propertyCollection->getItemByOrderPropertyId($prop->getPropertyId());
if ($newProp) {
$newProp->setValue($prop->getValue());
}
}
$basket = \Bitrix\Sale\Basket::create(SITE_ID);
$item = $basket->createItem('catalog', $productId);
$item->setField('QUANTITY', 1);
if ($props) {
$item->setField('PROPS', $props);
}
$order->setBasket($basket);
// If surcharge, use a coupon discount equal to originalPrice
if ($diffAmount < 0) {
// Refund the difference — create a discount for the amount (originalPrice - newPrice)
$order->getDiscount()->setData([
'COUPON_DISCOUNT' => abs($diffAmount),
]);
}
// Copy delivery
$shipmentCollection = $order->getShipmentCollection();
$shipment = $shipmentCollection->createItem();
$shipment->setField('DELIVERY_ID', $this->getDefaultDeliveryId());
$result = $order->save();
if (!$result->isSuccess()) {
throw new \RuntimeException('Exchange order failed: ' . implode('; ', $result->getErrorMessages()));
}
return $order->getId();
}
}
Syncing Exchange Statuses
When the return is completed (old item received) and the new order is paid, we mark the exchange as completed:
\Bitrix\Main\EventManager::getInstance()->addEventHandler(
'sale',
'OnSaleOrderReturnStatusChange',
function (\Bitrix\Main\Event $event) {
if ($event->getParameter('NEW_STATUS_ID') !== 'RECEIVED') return;
$returnId = $event->getParameter('RETURN_ID');
$exchange = ExchangeTable::getList([
'filter' => ['RETURN_ID' => $returnId],
'limit' => 1,
])->fetch();
if (!$exchange) return;
// Check if the new order is paid
$newOrder = \Bitrix\Sale\Order::load($exchange['NEW_ORDER_ID']);
if ($newOrder && $newOrder->isPaid()) {
ExchangeTable::update($exchange['ID'], ['STATUS' => 'completed']);
} else {
ExchangeTable::update($exchange['ID'], ['STATUS' => 'awaiting_payment']);
}
}
);
Notifying the Customer About the Exchange
class ExchangeNotifications
{
public static function sendExchangeCreated(int $exchangeId): void
{
$exchange = ExchangeTable::getById($exchangeId)->fetch();
$diffAmount = (float)$exchange['DIFF_AMOUNT'];
if ($diffAmount > 0) {
$message = "Exchange created. To complete, please pay an additional {$diffAmount} rub. "
. "Payment link: https://example.com/order/{$exchange['NEW_ORDER_ID']}/pay/";
} elseif ($diffAmount < 0) {
$message = "Exchange approved. After receiving the goods, we will refund "
. abs($diffAmount) . " rub. to your card.";
} else {
$message = "Exchange approved. New order #{$exchange['NEW_ORDER_ID']} will be sent "
. "after we receive the returned item.";
}
\CEvent::Send('EXCHANGE_CREATED', SITE_ID, [
'ORDER_ID' => $exchange['ORIGINAL_ORDER_ID'],
'NEW_ORDER_ID'=> $exchange['NEW_ORDER_ID'],
'MESSAGE' => $message,
]);
}
}
Typical Mistakes and Their Solutions
| Mistake | Solution |
|---|---|
| Original order discounts not considered | We store the final price in ORIGINAL_PRICE, not the base price |
| Lost link between return and new order | Highload-block local_sale_exchange fixes the relationship at DB level |
| Double charge during surcharge | New order is created with a discount equal to the return amount |
What's Included in the Work
| Component | Description |
|---|---|
Highload-block local_sale_exchange |
Stores the return → new order link, prices, and statuses |
ExchangeManager class |
Creates return + new order in a single transaction |
| Exchange form in personal account | Allows selection of new product, size, color |
| Surcharge handling | Automatic difference calculation, discount creation |
| Event handler | Auto-updates status when goods are received |
| Email notifications | On exchange creation, goods received, completion |
| Integration documentation | API description and data schema for your team |
| Staff training | Session for managers on handling exchanges |
| Post-implementation support | 1 month of maintenance for unforeseen situations |
How Long Does Setup Take?
Basic 1-to-1 exchange logic — 2–3 weeks. Exchange with surcharge and full automation — 4–6 weeks. Timelines depend on the complexity of integration with your 1C and payment gateways. We'll assess the project at the first meeting.
If you're dealing with manual exchanges, request a consultation. Want similar automation? Contact us to discuss your scenario and get a commercial proposal with a guaranteed result. Our team's experience: over 10 projects on product exchange for Bitrix.
Scalability and Practical Examples
On high-load projects (10+ exchanges per day), the system works stably thanks to transaction and queue usage. When two customers attempt an exchange simultaneously, the system ensures data consistency via database-level locks. Successful implementations include: an electronics marketplace with 15,000 SKUs, a cosmetics online store (5,000 products), and a specialty furniture store. In all cases, the average request processing time dropped from 3–5 days to 2–4 hours, and the number of errors decreased by 98%. The system integrates with any payment system and exports operations to 1C automatically.







