Баланс говорит, сколько у клиента денег. Леджер объясняет, откуда взялась эта цифра. Сегодня про него: как устроена таблица, почему в неё нельзя записать неверную строку и что происходит, когда одну и ту же операцию присылают дважды.
Только добавление
Леджер — таблица, в которую можно только вставлять. У репозитория нет методов update и delete для неё. Ошибку в леджере не исправляют, её объясняют новой записью.
В каждой строке: тип операции, сумма, баланс до, баланс после, ключ идемпотентности, ссылка на источник (джоба, платёж, возврат) и кто это сделал.
Семь типов операций
Три уменьшают баланс:
debit — списание за джобу,
refund — возврат клиенту через платёжного провайдера,
reversal — зарезервирован для внутренних отмен.
Три увеличивают:
credit — пополнение,
bonus — начисление сверх оплаты,
free_grant — стартовый грант.
Седьмой, adjustment, единственный со знаковой суммой: его пишет сверка, о которой ниже.
Направление операции задаётся типом, а не знаком. Сумма всегда положительная. Так по одной колонке видно, что это за операция, и нельзя случайно записать "списание на минус пять долларов".
Postgres проверяет арифметику
На таблице стоит CHECK:
(type IN ('debit','refund','reversal')
AND balance_after = balance_before - amount)
OR (type IN ('credit','bonus','free_grant')
AND balance_after = balance_before + amount)
OR type = 'adjustment'
Строку, в которой числа не сходятся, база отклонит, даже если в коде баг. Это последняя линия обороны, и она не зависит от того, кто и откуда пишет.
Откуда берутся баланс до и после? В прошлом посте списание делалось одним UPDATE с RETURNING, и именно возвращённое значение попадает в леджер. Одна транзакция: изменили баланс, получили новый остаток, вставили строку с ним. Разойтись эти два числа не могут.
Одна операция — одна запись
Мир вокруг нашего кода ненадёжен. Платежный провайдер присылает webhook об оплате, не получает ответ вовремя и присылает ещё раз. Воркер списания падает после UPDATE, но до commit, и повторяет цикл. Ретрай после сетевой ошибки. Во всех случаях операция одна, а попыток несколько.
Каждая операция несёт уникальный ключ: payment:<id платежа>, job:<id>:period:<начало периода>, refund:<id возврата>, grant:user:<id>. На колонке UNIQUE-индекс. Защита от дублирования на уровне БД.
Схема записи:
SAVEPOINT → UPDATE баланса → INSERT в леджер с ON CONFLICT DO NOTHING.
Если INSERT вернул строку, savepoint фиксируется. Если не вернул, ключ уже есть: savepoint откатывается, UPDATE баланса отменяется вместе с ним, а наружу возвращается существующая запись с пометкой "уже применено".
Почему не проверить ключ до UPDATE? Потому что между проверкой и вставкой второй процесс успеет сделать то же самое. Арбитром должен быть уникальный индекс, а не код. Savepoint нужен, чтобы откатить только эту операцию, не трогая внешнюю транзакцию: воркер списания держит блокировку на строке джобы, и терять её из-за дубликата нельзя.
Леджер это не двойная запись
В бухгалтерии каждая операция затрагивает два счёта, и сумма дебетов всегда равна сумме кредитов. У нас single-entry: одна строка, один аккаунт, направление в типе. Платформа ведёт только один вид счёта — обязательства перед клиентами. Выручка, комиссии провайдера и деньги на расчётном счёте живут в бухгалтерии. Если появятся внутренние взаиморасчёты между несколькими видами счетов, double-entry окупится. Пока это усложнение без выгоды.
Сверка
Баланс — денормализованная сумма леджера. Это ускоряет горячий путь, но два числа могут разойтись, если где-то есть баг. Раз в десять минут воркер считает по леджеру сумму со знаками для каждого аккаунта и сравнивает с хранимым остатком.
При расхождении: ERROR в лог и одна строка adjustment, где баланс до — сумма леджера, баланс после — хранимый остаток. Сам остаток не меняется: он источник истины для того, сколько клиенту можно тратить, и сверка не должна молча менять эту сумму. Она фиксирует разницу, чтобы леджер снова объяснял остаток целиком.
В норме таких строк ноль. Каждая — повод искать баг.