15.06.2026 10 мин чтения

UserBudgetPool: как Sale управляет внутренним счётом покупателя

Кирилл Новожилов

Кирилл Новожилов

Автор

UserBudgetPool: как Sale управляет внутренним счётом покупателя
Введение

Внутренний счёт в Sale — не просто поле CURRENT_BUDGET в b_sale_user_account. При оплате, возврате и отмене заказа ядро не пишет баланс сразу: операции накапливаются в Bitrix\Sale\Internals\UserBudgetPool, сериализуются через GET_LOCK и сбрасываются одним вызовом в конце Order::save(). Без понимания этого механизма легко получить «заказ сохранился, а баланс не изменился», расхождение при параллельных webhook-ах или ложное «недостаточно средств» в середине хита.

После прочтения вы сможете проследить полный цикл операции с внутренним счётом, диагностировать гонки и ошибку SALE_PROVIDER_USER_BUDGET_LOCKED, а также корректно читать «эффективный» баланс до коммита в БД.

Что вы узнаете

  • Как устроен UserBudgetPool и зачем Sale откладывает запись баланса.
  • Какие типы операций (BUDGET_TYPE_*) добавляются в пул и откуда они вызываются.
  • Как работает блокировка user_budget_{userId} через Connection::lock().
  • Какие методы использовать для чтения «живого» баланса: getUserBudgetByOrder(), getUserBudgetTransForOrder().
  • Как отлаживать типичные сбои: параллельные оплаты, LOCKED = Y на счёте, молчаливый пропуск операций.

Для кого

Разработчики, которые интегрируют оплату с внутреннего счёта, пишут кастомные обработчики возвратов или разбирают инциденты на production, когда баланс пользователя «плывёт».

Контекст: внутренний счёт и таблицы

Внутренний счёт покупателя хранится в b_sale_user_account (CSaleUserAccount). История движений — в b_sale_user_transact (CSaleUserTransact). Платёжная система «С внутреннего счёта» регистрируется через PaySystemInner с ACTION_FILE = INNER_BUDGET и обрабатывается классом Sale\Handlers\PaySystem\InnerHandler.

Прямой вызов CSaleUserAccount::UpdateAccount() из прикладного кода во время сохранения заказа — плохая идея: ядро само накапливает операции и вызывает UpdateAccount централизованно из UserBudgetPool::onUserBudgetSave().

Архитектура UserBudgetPool

Класс Bitrix\Sale\Internals\UserBudgetPool — синглтон на пользователя в рамках одного HTTP-хита. Статический массив $userBudgetPool хранит экземпляры по userId.

Жизненный цикл операции

        InnerHandler::creditNoDemand()
    → UserBudgetPool::addPoolItem()
        → pool->add()  // накопление в $items[]
            → lock()   // GET_LOCK при первом add()
    → Order::save()
        → PaymentCollection::save()
        → UserBudgetPool::onUserBudgetSave()
            → CSaleUserAccount::UpdateAccount() для каждой записи
            → pool->delete() + unlock()

    

Ключевой момент: запись в БД происходит не в момент списания, а в конце Order::save(), сразу после сохранения коллекции платежей.

Структура записи в пуле

Каждый элемент $items[] содержит:

Ключ Содержимое
SUM Сумма операции (отрицательная — списание, положительная — возврат)
CURRENCY Валюта заказа
TYPE Константа BUDGET_TYPE_*
ORDER Объект Order
PAYMENT Объект Payment (опционально)

Типы операций

Константы класса UserBudgetPool:

Константа Назначение Откуда вызывается
BUDGET_TYPE_ORDER_PAY Списание при оплате InnerHandler::creditNoDemand(), PaySystemInner::createOperation(DEBIT), Order::syncOrderPaid()
BUDGET_TYPE_ORDER_UNPAY Возврат на счёт InnerHandler::refund(), PaySystemInner::createOperation(CREDIT)
BUDGET_TYPE_ORDER_CANCEL_PART Частичная отмена оплаты Order::syncOrderPaymentPaid()
BUDGET_TYPE_ORDER_PAY_PART Частичная оплата (зарезервировано в ядре)
BUDGET_TYPE_EXCESS_SUM_PAID Переплата — возврат излишка Order::syncOrderPaymentCollectionPaid()
BUDGET_TYPE_OUT_CHARGE_OFF Внешнее списание (ручные операции)
BUDGET_TYPE_MANUAL Ручная корректировка (ручные операции)

При сбросе пула onUserBudgetSave() передаёт TYPE в CSaleUserAccount::UpdateAccount() как описание транзакции. При ошибке записи возвращается код SALE_PROVIDER_USER_BUDGET_{TYPE}_ERROR.

Блокировка GET_LOCK

Перед первым add() пул вызывает lock():

        // sale/lib/internals/userbudgetpool.php
protected function lock(): void
{
    if ($this->statusLock === self::STATUS_NOT_LOCKED) {
        $connection = Main\Application::getConnection();
        if (!$connection->lock($this->getUniqLockName())) {
            $this->statusLock = self::STATUS_LOCKED_EARLIER;
            return;
        }
        $this->statusLock = self::STATUS_LOCKED_NOW;
    }
}

private function getUniqLockName(): string
{
    return "user_budget_{$this->userId}";
}

    

Connection::lock() оборачивает MySQL GET_LOCK. Имя хешируется: md5(serverUniqId) . md5($name) — ограничение MySQL 5.7+ на 64 символа. Запрос идёт на master (ConnectionPool::useMasterOnly(true)).

Три состояния блокировки

Статус Значение Поведение
STATUS_NOT_LOCKED (0) Lock ещё не запрашивался Первый add() попытается взять lock
STATUS_LOCKED_NOW (1) Lock получен в этом хите Операции накапливаются нормально
STATUS_LOCKED_EARLIER (-1) Lock занят другим запросом add() молча возвращается; onUserBudgetSave() — ошибка

При STATUS_LOCKED_EARLIER текст ошибки: «Внутренний счет заблокирован другой сессией» (SALE_PROVIDER_USER_BUDGET_LOCKED).

Блокировка снимается в __destruct() пула, когда все записи удалены через delete() после успешного UpdateAccount.

Точки входа в ядре

InnerHandler — оплата и возврат

Sale\Handlers\PaySystem\InnerHandler — основной обработчик внутреннего счёта.

Списание (creditNoDemand):

        $userBudget = UserBudgetPool::getUserBudgetByOrder($order);
if ($userBudget >= $paymentSum) {
    UserBudgetPool::addPoolItem($order, ($paymentSum * -1), UserBudgetPool::BUDGET_TYPE_ORDER_PAY, $payment);
} else {
    // ORDER_PSH_INNER_ERROR_INSUFFICIENT_MONEY
}

    

Возврат (refund):

        UserBudgetPool::addPoolItem($order, $refundableSum, UserBudgetPool::BUDGET_TYPE_ORDER_UNPAY, $payment);

    

Перед обеими операциями проверяется isUserBudgetLock() — флаг LOCKED = Y в b_sale_user_account. Это отдельная блокировка от GET_LOCK пула: административная блокировка счёта пользователя.

Order::save() — финальный сброс

        // sale/lib/order.php, конец save()
Internals\UserBudgetPool::onUserBudgetSave($this->getUserId());

    

onUserBudgetSave() итерирует $pool->get() и для каждой записи вызывает:

        \CSaleUserAccount::UpdateAccount(
    $userId,
    $budgetDat['SUM'],
    $budgetDat['CURRENCY'],
    $budgetDat['TYPE'],
    $orderId,
    '',
    $paymentId
);

    

При неуспехе — Result с ошибками; при успехе — delete($key) и unlock().

PaySystemInner — legacy-контур

PaySystemInner::createOperation() дублирует логику для операций OPERATION_DEBIT, OPERATION_CREDIT, OPERATION_RETURN. Для возврата использует getUserBudgetTransForOrder() чтобы учесть незакоммиченные транзакции пула.

Чтение «живого» баланса

getUserBudget() — только БД

        UserBudgetPool::getUserBudget($userId, $currency);

    

Читает CURRENT_BUDGET из CSaleUserAccount::GetByUserId(). Игнорирует заблокированные счета (LOCKED = Ynull). Не учитывает пул.

getUserBudgetByOrder() — БД + пул

        UserBudgetPool::getUserBudgetByOrder($order);

    

Суммирует getUserBudget() и все SUM из незаписанных $items пула. Именно этот метод использует InnerHandler перед списанием — чтобы видеть баланс с учётом операций текущего хита.

getUserBudgetTransForOrder() — транзакции + пул

        UserBudgetPool::getUserBudgetTransForOrder($order);

    

Суммирует AMOUNT из CSaleUserTransact по ORDER_ID (с учётом DEBIT) и добавляет суммы из пула, кроме типа BUDGET_TYPE_ORDER_PAY. Нужен при расчёте возвратов, когда часть операций ещё не попала в b_sale_user_transact.

Практика: диагностика и отладка

Шаг 1. Проверить состояние счёта в БД

        SELECT USER_ID, CURRENT_BUDGET, CURRENCY, LOCKED
FROM b_sale_user_account
WHERE USER_ID = :userId;

    

Если LOCKED = YInnerHandler откажет до обращения к пулу (ORDER_PSH_INNER_ERROR_USER_BUDGET_LOCK).

Шаг 2. Посмотреть транзакции по заказу

        SELECT TRANSACT_DATE, AMOUNT, CURRENCY, DEBIT, DESCRIPTION, ORDER_ID, PAYMENT_ID
FROM b_sale_user_transact
WHERE ORDER_ID = :orderId
ORDER BY TRANSACT_DATE DESC;

    

Шаг 3. Проверить пул в runtime

        <?php
declare(strict_types=1);

use Bitrix\Main\Loader;
use Bitrix\Sale\Internals\UserBudgetPool;
use Bitrix\Sale\Order;

Loader::includeModule('sale');

$order = Order::load($orderId);
$userId = $order->getUserId();

// Эффективный баланс (БД + незаписанный пул)
$effectiveBudget = UserBudgetPool::getUserBudgetByOrder($order);

// Только БД
$dbBudget = UserBudgetPool::getUserBudget($userId, $order->getCurrency());

// Незаписанные операции текущего хита
$pool = UserBudgetPool::getUserBudgetPool($userId);
$pending = $pool->get() ?: [];

// Сумма транзакций + пул (для возвратов)
$transSum = UserBudgetPool::getUserBudgetTransForOrder($order);

    
Проверка
Если $effectiveBudget !== $dbBudget, в текущем хите есть незаписанные операции. После Order::save() они должны совпасть.

Шаг 4. Отловить SALE_PROVIDER_USER_BUDGET_LOCKED

ℹ️ Симптом Заказ сохранился, баланс не изменился, в Result заказа — предупреждение или ошибка с текстом «Внутренний счет заблокирован другой сессией».
Причина
Параллельный запрос (двойной webhook, одновременная оплата двух заказов одним пользователем) уже держит GET_LOCK на user_budget_{userId}. Второй запрос получил STATUS_LOCKED_EARLIER, его операции были проигнорированы.

Что делать:

  1. Проверить логи на дублирующиеся callback-и платёжных систем.
  2. Убедиться, что повторная обработка идемпотентна (проверка Payment::isPaid() до списания).
  3. Не вызывать onUserBudgetSave() вручную вне Order::save() — это нарушит порядок операций.

Шаг 5. Проверить GET_LOCK в MySQL

        SELECT IS_USED_LOCK(MD5(:serverUniqId) + MD5('user_budget_123')) AS lock_status;

    

Или через обёртку ядра:

        $connection = \Bitrix\Main\Application::getConnection();
$acquired = $connection->lock('user_budget_123', 0); // 0 = не ждать
if ($acquired) {
    $connection->unlock('user_budget_123');
}

    

Антипаттерны

Прямой UpdateAccount во время save()

        // ❌ Не делайте так внутри обработчиков оплаты
\CSaleUserAccount::UpdateAccount($userId, -500, 'RUB', 'MANUAL', $orderId);
$order->save(); // onUserBudgetSave() может перезаписать или конфликтовать

    

Ядро рассчитывает, что все операции идут через пул. Прямой вызов обходит блокировку и может привести к расхождению.

Чтение баланса через getUserBudget() при проверке оплаты

        // ❌ Устаревший баланс, если в этом хите уже было списание
$budget = UserBudgetPool::getUserBudget($userId, 'RUB');

// ✅ С учётом пула
$budget = UserBudgetPool::getUserBudgetByOrder($order);

    

Игнорирование Result после Order::save()

        $result = $order->save();
if (!$result->isSuccess()) {
    // Обязательно логируйте — сюда попадает SALE_PROVIDER_USER_BUDGET_LOCKED
}

    

Ручной addPoolItem без последующего save()

Операция останется в памяти пула и сбросится только при Order::save() или потеряется при завершении хита без save.

Заключение

UserBudgetPool — скрытый, но критичный слой Sale между бизнес-логикой оплаты и таблицей b_sale_user_account. Он решает две задачи: батчинг записей в рамках одного сохранения заказа и сериализация параллельных операций через GET_LOCK.

Для повседневной работы запомните три метода: getUserBudgetByOrder() — для проверки достаточности средств, getUserBudgetTransForOrder() — для возвратов, onUserBudgetSave() — финальный коммит (вызывается ядром, не вручную). При инцидентах ищите SALE_PROVIDER_USER_BUDGET_LOCKED и дублирующиеся webhook-и.

Опубликовано 1 день назад

Комментарии (0)

Пожалуйста, войдите в аккаунт, чтобы оставить комментарий

Оставить комментарий

Пока нет ни одного комментария. Будьте первым!

Мы используем файлы cookie для улучшения работы сайта. Продолжая использовать сайт, вы соглашаетесь с нашей политикой конфиденциальности.

AI Домовой История

0 / 25

Привет! Я помогу с вопросами по 1С-Битрикс.

Спрашивай про D7, ORM, компоненты или события.

Требуется авторизация

Войдите или зарегистрируйтесь, чтобы задавать вопросы AI-ассистенту.

Войти