Salescenter 26.200.0: терминальная оплата проверяет право на чтение элемента CRM
Обновление безопасности
Закрыта уязвимость или ослабленная проверка прав. Ставить в первую очередь.
В salescenter 26.200.0 (модуль «Продажи в чате» коробочного Битрикс24) экшен createTerminalPaymentAction() начал проверять право на чтение элемента CRM, для которого создаётся терминальная оплата. Вокруг НДС в слайдере приёма оплаты поменялись формат ставок в компоненте salescenter.app и смысл полей price и priceExclusive в ответе refreshBasket, а модулю теперь нужен catalog 26.400.0. В коробку версия вышла 22 сентября 2026 года (дата из официального канала «Битрикс24 changelog») вместе с catalog 26.400.0. Обновление заденет тех, кто вызывает экшены Order из своего PHP-кода, держит свой шаблон и JS слайдера или сам разбирает ответ refreshBasket.
Терминальная оплата требует права на чтение элемента CRM
В 26.100.0 права не проверял ни сам Bitrix\SalesCenter\Controller\Order::createTerminalPaymentAction(), ни Crm\Service\Sale\Terminal\PaymentService::createByProducts() из crm. Пользователь интранета мог создать терминальную оплату на любой элемент CRM, а к оплате подтягивался телефон основного контакта.
Теперь после загрузки элемента по ownerTypeId и ownerId экшен проверяет $userPermissions->item()->canReadItem($item). Без права на чтение он добавляет ошибку с текстом «Недостаточно прав для просмотра элемента CRM» (фраза SALESCENTER_CONTROLLER_ORDER_TERMINAL_PAYMENT_ACCESS_DENIED, код ошибки не задан) и возвращает пустой массив. Объект Bitrix\Crm\Service\UserPermissions контроллер получает через новый Order::getAutoWiredParameters(), поэтому AJAX-вызовы из слайдера работают как раньше.
Соседний updateTerminalPaymentAction(int $paymentId, array $options = []) проверку прав в экшене не получил. Мы поискали в crm/lib/Service/Sale/Terminal/PaymentService.php вхождения permission, canRead и canUpdate и не нашли ни одного.
Если в вашем модуле есть экшены, которые берут тип и ID элемента CRM из запроса, добавьте в них ту же проверку. UserPermissions удобно получать автоподстановкой, как теперь делает Order:
<?php declare(strict_types=1);
namespace Vendor\Shop\Infrastructure\Controller;
use Bitrix\Crm\Service\Container;
use Bitrix\Crm\Service\UserPermissions;
use Bitrix\Main\Engine\AutoWire\Parameter;
use Bitrix\Main\Engine\Controller;
use Bitrix\Main\Error;
use Bitrix\Main\Loader;
final class Document extends Controller
{
protected function init(): void
{
parent::init();
Loader::requireModule('crm');
}
public function getAutoWiredParameters(): array
{
return [
...parent::getAutoWiredParameters(),
new Parameter(
UserPermissions::class,
static fn(): UserPermissions => Container::getInstance()->getUserPermissions(),
),
];
}
public function createAction(UserPermissions $permissions, int $ownerTypeId, int $ownerId): ?array
{
$item = Container::getInstance()->getFactory($ownerTypeId)?->getItem($ownerId);
if ($item === null || !$permissions->item()->canReadItem($item)) {
$this->addError(new Error('Нет права на чтение элемента CRM', 'ACCESS_DENIED'));
return null;
}
// ...работа с $item
return ['id' => $item->getId()];
}
}
getFactory() вернёт null для неизвестного типа, getItem() для несуществующего ID, и оба случая уходят в ту же ветку с ошибкой, что и отказ в правах.
Что сломается
Передайте UserPermissions в createTerminalPaymentAction()
Сигнатура экшена теперь (UserPermissions $userPermissions, array $basketItems = [], array $options = []). Код, который вызывал его из PHP с позиционными $basketItems, $options, передаст массив корзины в первый параметр и получит TypeError. Передавайте первым аргументом UserPermissions, например Container::getInstance()->getUserPermissions() из crm.
vatList в salescenter.app стал vatRateList
Раньше $arResult['vatList'] был отсортированным списком ставок-чисел. Теперь вместо него $arResult['vatRateList'], массив элементов ['taxId' => int, 'value' => ...], отсортированный по значению ставки, а при равных значениях по исходному порядку. template.php отдаёт весь $arResult в JS как options через Json::encode(), так что переименование видно и в JS. Дальше product.js передаёт список в catalog.product-form опцией taxRateList вместо taxList, эту опцию мы разбирали в статье про catalog 26.400.0.
Кастомный шаблон или JS, который читает vatList, после обновления останется без ставок. Если нужен прежний плоский список, его можно собрать из нового:
<?php declare(strict_types=1);
if (!defined('B_PROLOG_INCLUDED') || B_PROLOG_INCLUDED !== true) {
die();
}
/** @var array $arResult */
// плоский список значений, как в прежнем vatList
$vatValues = array_column($arResult['vatRateList'] ?? [], 'value');
В JS то же самое даёт options.vatRateList.map((rate) => rate.value).
Ответ refreshBasketAction() считается иначе
В ответе Order::refreshBasketAction() поменялся смысл полей позиции и итогов:
priceтеперь брутто,priceExclusiveнетто, а раньше в оба поля шёлBasketItem::getPrice();- добавились поля
vatAmount,vatRate,vatIncludedиtaxSum; discountпереводится из брутто-координат sale в нетто черезDiscountFormatter::grossToNet(),discountRateокругляется до 4 знаков вместо 2;- блок
totalсчитает сервисsale.basketCalculator(privateaggregateCurrentPaymentTotals()) вместо ручного суммирования, скидка в итогах тоже переводится в нетто черезgrossToNet().
Если ваш JS разбирает этот ответ сам, сверьте его на товаре с НДС сверху (taxIncluded = 'N'). На стороне sale в 26.500.0 конвертер строк той же формы товаров тоже по-новому выбирает цену в зависимости от taxIncluded, подробности в разборе sale 26.500.0.
Без catalog 26.400.0 не обновляйтесь
fillVat() в salescenter.app вызывает BasketItem::setTaxSum() из catalog/lib/v2/Integration/JS/ProductForm/BasketItem.php. В catalog 26.300.100, который стоял у нас до этого обновления, такого метода нет, как нет и опции taxRateList в JS-форме товаров. Если ставите обновления выборочно, берите salescenter 26.200.0 только вместе с catalog 26.400.0.
Цены из слайдера помечаются ручными
Order::processBasketItems() ставит каждой строке isCustomPrice = 'Y'. После сборки заказа buildOrder() вызывает private restoreCustomPriceBasePrices(), и тот возвращает BASE_PRICE и DISCOUNT_PRICE из формы, если Basket::refreshData() перезаписал их ценой каталога. Строки сопоставляются по PRODUCT_ID, товары с расходящимися базовыми ценами в строках пропускаются.
Ещё refreshBasketAction(), refreshDeliveryAction() и getCompatibleDeliverySystemsAction() теперь вызывают VatRate::prepareTaxPrices() перед сборкой заказа, как уже делали экшены, которые сохраняют заказ.
Новое
НДС и суммы модуль теперь считает DI-сервисами sale вместо Bitrix\Sale\Tax\VatCalculator. Это sale.basketItemInputFactory, sale.basketCalculator, sale.basketItemCalculator, sale.vatCalculator и sale.priceRounder. Переход затронул контроллер Order (в том числе obtainPaymentFields()), OrderBuilder::buildPayments(), Component\VatRate и класс компонента salescenter.app.
Новых публичных методов три, ещё у одного поменялось поведение:
VatRate::restoreGrossDisplayPrice(array $item): arrayвозвращает брутто вpriceдля строки сtaxIncluded = 'N', которую нельзя пересчитать от позиции корзины. Без него цена такой строки уменьшалась на каждом refresh.Bitrix\Salescenter\Internal\Service\DiscountFormatter::grossToNet(float $discountGross, float $vatRatePercent, bool $vatIncluded): floatпереводит скидку из брутто-координат sale в нетто для UI черезsale.vatCalculator. Класс final и лежит в пространствеInternal.Order::getAutoWiredParameters()добавляет к параметрам родителя автоподстановкуBitrix\Crm\Service\UserPermissions.- Изменённый
VatRate::prepareTaxPrices()пишет в строкуtaxRate, дляtaxIncluded = 'N'пересчитываетpriceExclusiveиз брутто-priceи поправляетbasePriceчерез privatecanonicalizeNetBasePrice(), если значение совпало с брутто-вариантом с допуском 0.01.
В $arResult компонента salescenter.app появились pricePrecision из sale.priceRounder, displayPrecision и currencyFormat из CCurrencyLang::GetFormatDescription(), а в итогах getTotalSumList() поле taxSum. В форму товаров JS теперь передаёт pricePrecision, displayPrecision, showTaxSettingsSwitcher: 'Y' и taxIncluded по первой строке корзины. Что включает showTaxSettingsSwitcher, описано в разборе catalog 26.400.0.
Перед отправкой оплаты app.js в трёх местах эмитит JS-событие salescenter.app:onbeforepaymentsend. На него подписан components/stage-blocks/message-mixin.js, который вызывает messageSenderEditor.handleSendAttempt(), если такой метод есть.
БД и установщик
Схема прежняя. Файлы install/db/mysql/install.sql, uninstall.sql и их pgsql-копии удалены, InstallDB() и UnInstallDB() работают через CModule::installMigrations() и uninstallMigrations(). Таблицы b_salescenter_page, b_salescenter_meta и b_salescenter_page_param описаны в install/migrations/tables.php. Колонки и индексы совпадают с удалённым MySQL-скриптом, только целочисленные колонки объявлены без ширины отображения: int вместо int(10) и int(18). ID таблицы b_salescenter_page_param задан шорткатом $table->addId(), он даёт тот же INT NOT NULL AUTO_INCREMENT с первичным ключом.
В install/migrations/events.php лежат 19 обработчиков событий: девять от landing, шесть от sale, два от messageservice и по одному от pull и notifications. В migration_config.json в качестве defaultTableName указана b_salescenter_page, в маппинг каталогов входят install/components и install/js.
При переезде потерялось снятие обработчика crm:OnActivityAdd → CrmManager::onActivityAdd. Оно было только в старом UnInstallDB(), в events.php этот обработчик не попал, и при удалении модуля он больше не снимается.
Мелочи и находки
- Комментарий в
fillVat()описывает исправленную ошибку с двойным НДС приtaxIncluded = 'N'. ВызовsetPriceExclusive($priceWithVat)заменили наsetPriceExclusive($price). В примере из комментария товар за 100 показывался за 122, а к оплате шло 148.84, цифры соответствуют ставке 22%. - В комментариях
lib/component/vatrate.phpупоминаются «jabber #248006» и «ALG-01 / VatRateTest». Тестов в поставке модуля нет. formattedPriceвfillResultBasket()по-прежнему строится изBasketItem::getPrice(), аpriceрядом уже брутто из калькулятора.- Для платёжного обработчика
platonрасширение иконки захардкожено как($handler === 'platon') ? 'webp' : 'svg', причём в трёх местах:lib/component/paysystem.php,salescenter.app/class.phpи шаблонsalescenter.paysystem. - В редакторе сообщения вне SMS-контекста выбор и создание пользовательского шаблона показываются только CRM-админам. Вызов прикрыт
method_exists($editorLayout, 'setCustomTemplateSelectorShown'), а в комментарии сказано, что оба сеттера приходят вместе в messageservice. В JS так же защищёнhandleSendAttempt(), с пометкой, что метод приходит из более нового бандла crm. - BBCode-плейсхолдер ссылки на оплату получил атрибут
salescenterPaymentLink=true(message-mixin.js). - Из зависимостей расширения
salescenter.appубралиcurrencyиlanding.pageobject. Валюты теперь форматируетCurrencyCoreизcurrency.currency-coreсloadCurrencyFormat(). Если ваш JS рассчитывал, что эти расширения подтянетsalescenter.app, подключите их сами. - Svg-иконки касс Бизнес.Ру и платёжных систем Platon оказались обёртками над base64-PNG. Их заменили на webp, у части появились версии
-2x. Иконка вcomponent.cssиз base64-PNG стала встроенным SVG, а вbundle.config.jsприложения добавилиcssImages: { type: 'inline', maxSize: 7.25 }. На картинки приходится больше двух третей файлов в диффе. - В
salescenter.page.includeвызов\CHTTP::urlAddParams()заменили наBitrix\Main\Web\Uri::addParams(), а открывающий тег<?на<?php. - В модуле появился автосгенерированный
.phpstorm.meta.phpс картой сервисовsalecenter.component.paysystemиsalecenter.integration.salemanager. Коды сервисов пишутся черезsalecenter, безs. - Новых
@deprecatedв релизе нет.
Что делать
- Если вызываете
Order::createTerminalPaymentAction()из PHP, передавайте первым аргументомUserPermissions. - Добавьте
canReadItem()в свои экшены, которые получают ID элемента CRM из запроса. - Найдите
vatListв кастомных шаблонах и JS слайдера и перейдите наvatRateList. - Ставьте salescenter 26.200.0 только вместе с catalog 26.400.0.
- Сверьте поля
price,priceExclusiveиtotalв своём JS, который читает ответrefreshBasket, на товаре с НДС сверху. - Соберите тестовую оплату из слайдера с ценой, отличной от каталожной, и со скидкой, и проверьте цены в получившемся заказе.
Устаревшие и удалённые API в этой версии
| Символ | Статус | Чем заменять |
|---|---|---|
salescenter.app: $arResult['vatList']
|
Удалено |
$arResult['vatRateList']
|