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 = Y → null). Не учитывает пул.
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 = Y — InnerHandler откажет до обращения к пулу (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 заказа — предупреждение или ошибка с текстом «Внутренний счет заблокирован другой сессией».GET_LOCK на user_budget_{userId}. Второй запрос получил STATUS_LOCKED_EARLIER, его операции были проигнорированы.Что делать:
- Проверить логи на дублирующиеся callback-и платёжных систем.
- Убедиться, что повторная обработка идемпотентна (проверка
Payment::isPaid()до списания). - Не вызывать
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-и.
Комментарии (0)
Пожалуйста, войдите в аккаунт, чтобы оставить комментарий
Оставить комментарийПока нет ни одного комментария. Будьте первым!