Поле 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_ALLOCATEDAllocation — часть аудита. После проведения расхода нельзя молча пересчитывать старые доли по новому составу участников.
Исправления и аудит
Проведённые движения лучше не редактировать и не удалять из интерфейса. Ошибка исправляется компенсирующим движением с ссылкой на исходное. Администратор указывает причину, а audit log фиксирует пользователя и время.
Полезные инварианты для тестов:
- сумма allocations равна расходу;
- сумма возвратов не превышает поступление;
- один платёж создаёт не более одного поступления;
- баланс равен агрегации всех движений;
- расход не переводит фонд в минус;
- пересчёт проекции даёт тот же результат после любого порядка чтения.
Практический вывод
Ledger немного сложнее одного поля BALANCE, но даёт воспроизводимость, идемпотентность и понятный аудит. Любое изменение денег представлено отдельным фактом, возврат не стирает поступление, а распределение сохраняет исторические доли.
Начинайте с неизменяемых движений и уникальных ключей источника. Оптимизацию чтения добавляйте позже как пересобираемую проекцию. Тогда даже после сбоя или ручной ошибки баланс можно доказать, а не только показать.