Перейти к содержанию
Innovialabs

Проектирование базы данных для платежей и балансов: ledger

Продвинутое4 мин

Коротко

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

Короткий ответ

Надёжный платёжный ledger держится на пяти правилах:

  1. Двойная запись: каждая операция переводит деньги минимум между двумя счетами, и сумма её проводок равна нулю.
  2. Неизменяемость: записи никогда не обновляются и не удаляются; ошибки исправляются новой сторнирующей транзакцией.
  3. Только целые числа: суммы — bigint в минимальных единицах, тийинах или центах, никогда не float.
  4. Идемпотентность: повторный колбэк от Payme, Click или Uzum не может создать второй платёж.
  5. Блокировки: две параллельные покупки не могут обе потратить один и тот же баланс.

Простая колонка users.balance, которую увеличивают и уменьшают, не выполняет ни одного из этих правил: нельзя объяснить, откуда взялось число, и доказать, что оно верное.

Схема

CREATE TABLE accounts (
  id          bigserial PRIMARY KEY,
  owner_id    bigint,
  kind        text   NOT NULL,              -- user_wallet, provider_clearing, revenue
  currency    char(3) NOT NULL DEFAULT 'UZS',
  balance     bigint NOT NULL DEFAULT 0,    -- кэш, в тийинах
  allow_negative boolean NOT NULL DEFAULT false
);

CREATE TABLE ledger_transactions (
  id              bigserial PRIMARY KEY,
  idempotency_key text NOT NULL UNIQUE,
  kind            text NOT NULL,            -- topup, purchase, refund, reversal
  created_at      timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE ledger_entries (
  id             bigserial PRIMARY KEY,
  transaction_id bigint NOT NULL REFERENCES ledger_transactions(id),
  account_id     bigint NOT NULL REFERENCES accounts(id),
  amount         bigint NOT NULL CHECK (amount <> 0)  -- + увеличивает, - уменьшает
);

Здесь используется упрощённое правило знаков: положительная сумма увеличивает баланс счёта, отрицательная — уменьшает. В каждой транзакции SUM(amount) = 0 — проверяйте это в той же транзакции базы, которая пишет проводки, или отложенным триггером-ограничением.

Пополнение на 50 000 сум через провайдера превращается в две проводки: −5 000 000 на счёте provider_clearing и +5 000 000 на кошельке пользователя. Деньги не появились из ниоткуда: клиринговый счёт показывает, сколько провайдер вам должен до момента расчёта. Поэтому у таких системных счетов allow_negative = true, а у кошельков пользователей — нет.

Деньги как целые числа

  • float не может точно представить многие десятичные дроби, и ошибки накапливаются в суммах.
  • numeric точен, но медленнее и провоцирует хранить доли тийина.
  • bigint в тийинах точен, быстр и прост. Форматируйте в сумы только при выводе.

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

Неизменяемые записи

  • Отзовите у роли приложения UPDATE и DELETE на таблицы ledger или заблокируйте их триггером.
  • Возврат — это новая транзакция с зеркальными проводками и ссылкой на исходную.
  • Статусы (ожидает, проведён, отменён) относятся к объекту платежа, а не к проводкам. В ledger пишется только то, что с деньгами уже произошло.

Идемпотентные колбэки

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

INSERT INTO ledger_transactions (idempotency_key, kind)
VALUES ('payme:6720f1c2a9', 'topup')
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id;

Если ничего не вернулось, операция уже обработана: найдите её и отправьте провайдеру тот же ответ, что и в первый раз. У каждого протокола провайдера свои шаги (проверка, создание, проведение, отмена), и каждый шаг должен безопасно выдерживать повтор.

Блокировки против двойного списания

BEGIN;
SELECT balance FROM accounts WHERE id = 42 FOR UPDATE;
-- если balance < 1500000: ROLLBACK и ответ «недостаточно средств»
INSERT INTO ledger_transactions (idempotency_key, kind)
VALUES ('order:981', 'purchase') RETURNING id;          -- :tx
INSERT INTO ledger_entries (transaction_id, account_id, amount)
VALUES (:tx, 42, -1500000), (:tx, 7, 1500000);
UPDATE accounts SET balance = balance - 1500000 WHERE id = 42;
UPDATE accounts SET balance = balance + 1500000 WHERE id = 7;
COMMIT;
  • FOR UPDATE заставляет вторую параллельную покупку ждать, пока первая не завершится, и затем увидеть новый баланс.
  • Блокируйте несколько счетов в фиксированном порядке (например, по id), чтобы избежать дедлоков.
  • Системный счёт вроде выручки становится горячей строкой. Варианты: не кэшировать его баланс и считать из проводок либо разбить на несколько субсчетов.
  • Альтернатива — уровень изоляции SERIALIZABLE с повтором при ошибках сериализации.

Балансы и сверка

Кэшированный balance — удобство, источник истины — сумма проводок. Регулярно проверяйте, что они совпадают:

SELECT a.id, a.balance, COALESCE(SUM(e.amount), 0) AS from_entries
FROM accounts a LEFT JOIN ledger_entries e ON e.account_id = a.id
GROUP BY a.id HAVING a.balance <> COALESCE(SUM(e.amount), 0);

При больших объёмах храните периодические снимки балансов и суммируйте только более новые проводки.

Сверка сравнивает ваш ledger с отчётами провайдеров: каждой транзакции провайдера должна соответствовать ровно одна транзакция в ledger с той же суммой. Разложите расхождения на «нет в ledger», «нет у провайдера» и «не совпадает сумма» и устраняйте их только новыми корректирующими транзакциями.

Частые вопросы

Для прототипа — да. Но без проводок вы не ответите, откуда пришли деньги, не разберёте спор и не найдёте ошибку. Добавить ledger позже значит мигрировать историю, которой у вас нет.

Поможем с этим

Если хотите не разбираться самим, а получить результат — этим мы занимаемся каждый день.

Обсудить проект

Читайте также

Есть идея или задача? Расскажите нам

Ответим в течение рабочего дня и предложим решение. Консультация бесплатная.