Developing a Product Return Request Form for 1C-Bitrix
The standard bitrix:sale.order.return.edit component works, but in real-world projects it falls short: it does not support uploading defect photographs, has no step-by-step wizard interface, and provides no way to specify different reasons for individual order line items. With 50–100 return requests per day, an inconvenient form translates directly into wasted manager time spent on follow-up phone calls.
A custom return request form is built on top of the sale module API and must solve concrete problems: gather sufficient information on the first contact, avoid overwhelming the customer, and automatically create a properly populated request in the system.
Wizard form structure
The optimal UX for a return form is 3–4 steps:
- Order selection — the customer picks from their order history eligible for return
- Item and reason selection — checkboxes to select line items; for each item the customer specifies the reason and quantity
- Additional information — comment, photo/document upload
- Confirmation — final screen with the request details and instructions
Step 1: orders available for return
A return is only possible for paid orders within a defined period (typically 14 days by law). Loading the list:
namespace Local\Returns;
class ReturnableOrdersProvider
{
private int $userId;
private int $returnWindowDays;
public function __construct(int $userId, int $returnWindowDays = 14)
{
$this->userId = $userId;
$this->returnWindowDays = $returnWindowDays;
}
public function getReturnableOrders(): array
{
\Bitrix\Main\Loader::includeModule('sale');
$dateFrom = new \Bitrix\Main\Type\Date();
$dateFrom->add('-' . $this->returnWindowDays . ' days');
$result = \Bitrix\Sale\OrderTable::getList([
'filter' => [
'USER_ID' => $this->userId,
'PAYED' => 'Y',
'>=DATE_PAY' => $dateFrom,
'!STATUS_ID' => ['CANCELED', 'RETURNED'],
],
'select' => ['ID', 'ACCOUNT_NUMBER', 'DATE_INSERT', 'PRICE', 'CURRENCY', 'STATUS_ID'],
'order' => ['DATE_INSERT' => 'DESC'],
]);
$orders = [];
while ($row = $result->fetch()) {
// Check: no full return already exists for this order
if (!$this->hasFullReturn($row['ID'])) {
$orders[] = $row;
}
}
return $orders;
}
private function hasFullReturn(int $orderId): bool
{
$existing = \Bitrix\Sale\OrderReturnTable::getList([
'filter' => ['ORDER_ID' => $orderId, 'STATUS_ID' => ['APPROVED', 'RECEIVED', 'REFUND']],
'select' => ['ID'],
'limit' => 1,
])->fetch();
return (bool)$existing;
}
}
Step 2: order line items with reason selection
class OrderItemsProvider
{
public function getReturnableItems(int $orderId, int $userId): array
{
$order = \Bitrix\Sale\Order::load($orderId);
if (!$order || $order->getUserId() !== $userId) {
throw new \RuntimeException('Order not found or access denied');
}
$items = [];
foreach ($order->getBasket() as $item) {
// Calculate already returned quantity
$returnedQty = $this->getReturnedQuantity($orderId, $item->getId());
$availableQty = $item->getQuantity() - $returnedQty;
if ($availableQty <= 0) continue;
$items[] = [
'basket_id' => $item->getId(),
'product_id' => $item->getProductId(),
'name' => $item->getField('NAME'),
'quantity' => $item->getQuantity(),
'available_qty' => $availableQty,
'price' => $item->getFinalPrice(),
'image' => $this->getProductImage($item->getProductId()),
'article' => $item->getField('ARTICLE'),
];
}
return $items;
}
private function getReturnedQuantity(int $orderId, int $basketItemId): float
{
$result = \Bitrix\Sale\OrderReturnBasketTable::getList([
'filter' => [
'ORDER_RETURN.ORDER_ID' => $orderId,
'BASKET_ID' => $basketItemId,
'ORDER_RETURN.STATUS_ID' => ['WAIT', 'REVIEW', 'APPROVED', 'RECEIVED', 'REFUND'],
],
'runtime' => [
new \Bitrix\Main\ORM\Fields\ExpressionField('TOTAL_QTY', 'SUM(%s)', 'QUANTITY'),
],
'select' => ['TOTAL_QTY'],
])->fetch();
return (float)($result['TOTAL_QTY'] ?? 0);
}
}
Client-side: step-by-step form
React component for the wizard form (or Vue — by preference):
function ReturnWizard({ orderId }) {
const [step, setStep] = useState(1);
const [selectedItems, setSelectedItems] = useState([]);
const [files, setFiles] = useState([]);
const returnReasons = [
{ id: 'defect', label: 'Manufacturing defect' },
{ id: 'wrong_item', label: 'Wrong item sent' },
{ id: 'damaged', label: 'Damaged in transit' },
{ id: 'not_fit', label: 'Did not fit' },
{ id: 'other', label: 'Other reason' },
];
const canProceed = selectedItems.some(item => item.selected && item.reason);
async function submitReturn() {
const formData = new FormData();
formData.append('order_id', orderId);
formData.append('sessid', BX.bitrix_sessid());
formData.append('items', JSON.stringify(selectedItems.filter(i => i.selected)));
files.forEach((file, i) => formData.append(`files[${i}]`, file));
const res = await fetch('/local/api/return-submit.php', {
method: 'POST',
body: formData,
});
const data = await res.json();
if (data.success) {
setStep(4); // Success screen
}
}
// ... render steps
}
Server-side handler for final submission
// /local/api/return-submit.php
require_once($_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php');
header('Content-Type: application/json');
if (!\CUser::IsAuthorized()) {
http_response_code(401);
exit(json_encode(['error' => 'Unauthorized']));
}
if (!\bitrix_sessid_check($_POST['sessid'] ?? '')) {
http_response_code(403);
exit(json_encode(['error' => 'Invalid session']));
}
$orderId = (int)($_POST['order_id'] ?? 0);
$items = json_decode($_POST['items'] ?? '[]', true);
$userId = (int)\CUser::GetID();
// Validate that the order belongs to the user
$validator = new \Local\Returns\ReturnValidator($userId);
if (!$validator->canReturnOrder($orderId)) {
exit(json_encode(['success' => false, 'error' => 'Order is not available for return']));
}
// Upload attached files
$fileIds = [];
$uploader = new \Local\Upload\FileUploader();
foreach ($_FILES as $key => $file) {
if (strpos($key, 'files') === 0 && $file['error'] === UPLOAD_ERR_OK) {
try {
$result = $uploader->handle($file);
$fileIds[] = $result['id'];
} catch (\Exception $e) {
// Log but do not abort
}
}
}
// Create the return request
$manager = new \Local\Returns\ReturnManager();
$returnId = $manager->createReturn($orderId, $items, 'MONEY');
// Attach files to the request
if ($fileIds) {
\Local\Returns\ReturnAttachments::attach($returnId, $fileIds);
}
// Send notifications
\Local\Returns\Notifications::sendToCustomer($returnId);
\Local\Returns\Notifications::sendToManager($returnId);
exit(json_encode([
'success' => true,
'return_id' => $returnId,
'message' => 'Request #' . $returnId . ' created. We will review it within 2 business days.',
]));
Attachments to the request: extending the table
The standard Bitrix return system does not store attached files. We extend it via a Highload block:
class ReturnAttachmentTable extends \Bitrix\Main\ORM\Data\DataManager
{
public static function getTableName(): string { return 'local_return_attachments'; }
public static function getMap(): array
{
return [
new \Bitrix\Main\ORM\Fields\IntegerField('ID', ['primary' => true, 'autocomplete' => true]),
new \Bitrix\Main\ORM\Fields\IntegerField('RETURN_ID'),
new \Bitrix\Main\ORM\Fields\IntegerField('FILE_ID'), // b_file.ID
new \Bitrix\Main\ORM\Fields\DatetimeField('CREATED_AT'),
];
}
}
Scope of work
- Step 1: list of returnable orders with period and status validation
- Step 2: item selection with return reasons and quantities
- Step 3: photo/document upload via FileUploader
- Step 4: confirmation, instructions for shipping the item back
- Server-side handler: validation, return creation via Sale API
- Email notifications: to the customer (confirmation) + to the manager (new request)
- "My Returns" page in the personal account with statuses
Timeline: full form with wizard and file upload — 2–4 weeks.







