К другим статьям
1С-БитриксD7 ORMLedgerУчёт
Для разработчиков

Ledger на D7 ORM: баланс фонда как сумма движений

Поле BALANCE кажется самым простым способом вести фонд: при поступлении прибавить сумму, при расходе вычесть. Но после первого сбоя между платежом и обновлением баланса число перестаёт объяснять, откуда оно взялось. Исправление вручную уничтожает историю, а повтор webhook способен начислить деньги дважды.

В zr.paidaccess используется другой принцип: баланс не хранится отдельным полем, а вычисляется по движениям.

баланс = сумма поступлений - сумма списаний

Ledger становится источником истины. Текущий баланс — его проекция, которую всегда можно пересчитать.

Минимальная модель

Для фонда достаточно трёх таблиц:

  • fund — фонд и его привязка к сайту;
  • fund_movement — поступления и списания;
  • fund_expense_allocation — доли участников в конкретном списании.

У движения нужны FUND_ID, TYPE, AMOUNT, SOURCE, идентификатор источника, дата и служебный комментарий. Сумма хранится целым числом в минимальных денежных единицах.

Схематичный D7 map:

public static function getMap(): array
{
    return [
        (new IntegerField('ID'))->configurePrimary()->configureAutocomplete(),
        (new IntegerField('FUND_ID'))->configureRequired(),
        (new EnumField('TYPE'))
            ->configureValues(['income', 'expense'])
            ->configureRequired(),
        (new IntegerField('AMOUNT'))->configureRequired(),
        new StringField('SOURCE'),
        new StringField('SOURCE_KEY'),
        new DatetimeField('CREATED_AT'),
    ];
}

Пример сокращён: в production нужны reference-поля, валидаторы и индексы. Главное ограничение — AMOUNT > 0; направление задаёт TYPE, а не знак суммы.

Расчёт баланса

Баланс можно получить агрегатом:

$row = FundMovementTable::getList([
    'select' => [
        'TOTAL' => new ExpressionField(
            'TOTAL',
            "SUM(CASE WHEN %s = 'income' THEN %s ELSE -%s END)",
            ['TYPE', 'AMOUNT', 'AMOUNT']
        ),
    ],
    'filter' => ['=FUND_ID' => $fundId],
])->fetch();

$balance = (int) ($row['TOTAL'] ?? 0);

Конкретный SQL CASE стоит проверить на используемой СУБД. Для переносимости выражение можно инкапсулировать в repository. Если чтений очень много, допустима кэшированная проекция, но она не становится источником истины и должна пересобираться из ledger.

Поступление от платежа

В фонд попадает только фондовая часть платежа. Налог, обслуживание сайта и иные составляющие могут входить в сумму банка, но не увеличивают фонд. Сервис получает уже рассчитанную FUND_AMOUNT.

Для защиты от повторов нужен уникальный бизнес-ключ, например (FUND_ID, SOURCE, SOURCE_KEY, TYPE), где SOURCE_KEY — внутренний идентификатор платежа. Метод recordPaymentIncome() сначала пытается вставить движение; конфликт уникальности означает, что поступление уже учтено.

Проверка SELECT, затем INSERT без ограничения базы небезопасна: два параллельных webhook пройдут проверку одновременно.

Возврат — отдельное движение

Возврат не удаляет исходное поступление и не меняет его сумму. Он создаёт expense с источником refund и ссылкой на платёж. Так отчёт отвечает на вопросы: сколько было внесено, сколько возвращено и когда изменился баланс.

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

Тестовый платёж также не должен попадать в фонд. В zr.paidaccess для него используется служебный billing period GT; отбор выполняется до создания движения.

Ручное списание и гонки

Перед расходом нужно проверить доступный баланс. Проверка и вставка выполняются в одной транзакции с блокировкой фонда или другой сериализацией:

$connection->startTransaction();

try {
    $this->funds->lock($fundId);
    $balance = $this->movements->getBalance($fundId);

    if ($balance < $amount) {
        throw new InsufficientFundBalance();
    }

    $movementId = $this->movements->addExpense($fundId, $amount, 'admin');
    $connection->commitTransaction();
} catch (\Throwable $error) {
    $connection->rollbackTransaction();
    throw $error;
}

Без блокировки два списания могут одновременно увидеть достаточный баланс и увести фонд в минус.

Распределение между участниками

Общее списание можно разложить на доли участников с положительным остатком вклада. В zr.paidaccess предусмотрены режимы even и random. Результат записывается в отдельную таблицу allocation.

Для равного распределения целочисленная сумма может не делиться без остатка. Алгоритм сначала выдаёт каждому базовую долю, затем распределяет оставшиеся минимальные единицы в стабильном порядке. Сумма allocations обязана точно совпасть с расходом.

Персональная проекция участника:

NET_BALANCE =
    TOTAL_CONTRIBUTED
    - TOTAL_REFUNDED
    - TOTAL_ALLOCATED

Allocation — часть аудита. После проведения расхода нельзя молча пересчитывать старые доли по новому составу участников.

Исправления и аудит

Проведённые движения лучше не редактировать и не удалять из интерфейса. Ошибка исправляется компенсирующим движением с ссылкой на исходное. Администратор указывает причину, а audit log фиксирует пользователя и время.

Полезные инварианты для тестов:

  • сумма allocations равна расходу;
  • сумма возвратов не превышает поступление;
  • один платёж создаёт не более одного поступления;
  • баланс равен агрегации всех движений;
  • расход не переводит фонд в минус;
  • пересчёт проекции даёт тот же результат после любого порядка чтения.

Практический вывод

Ledger немного сложнее одного поля BALANCE, но даёт воспроизводимость, идемпотентность и понятный аудит. Любое изменение денег представлено отдельным фактом, возврат не стирает поступление, а распределение сохраняет исторические доли.

Начинайте с неизменяемых движений и уникальных ключей источника. Оптимизацию чтения добавляйте позже как пересобираемую проекцию. Тогда даже после сбоя или ручной ошибки баланс можно доказать, а не только показать.

Нужен модуль или интеграция?

Разберём архитектуру вашей задачи на 1С-Битрикс

Опишите сценарий и ограничения проекта — предложу безопасную схему реализации, оценку и следующий шаг.