У клиента в кошельке 100 долларов. Он оплачивает покупку на 30, и приложение показывает: доступно 70. Продавец деньги ещё не получил. В банковской выписке платеж тоже может появиться позже.
Все эти значения могут быть правильными. Они описывают разные этапы одной операции.
Чтобы разобраться в устройстве финансовой системы, полезно разделить три вещи: поручение выполнить платеж, записи о том, кому и сколько причитается, и подтверждение того, что произошло за пределами системы. Архитектура финансового учета во многом нужна для того, чтобы связать эти вещи, не подменяя одну другой.
Открыть схему в полном размере
Начнем с баланса на экране
В нашем примере кошелек предоплаченный: кредитного лимита, овердрафта и других ограничений на средства нет.
До покупки учтенный баланс составляет 100 долларов. В схеме он называется posted: сумма отражена в проведенных учетных записях. Когда клиент начинает покупку, система резервирует 30 долларов. Они еще не списаны с учтенного баланса, но потратить их на другую покупку уже нельзя.
- До покупки: учтено 100, зарезервировано 0, доступно 100
- Пока 30 в резерве: учтено 100, зарезервировано 30, доступно 70
- После проведения покупки: учтено 70, зарезервировано 0, доступно 70
Если покупку отменили до проведения- резерв можно освободить. Клиенту снова доступны 100 долларов. Для этого не нужно сначала проводить списание, а затем оформлять возврат.
Это пример конкретной модели, а не универсальная формула. Входящие переводы, кредитные лимиты, комиссии и ограничения могут влиять на доступную сумму. У провайдеров различаются и названия балансов. Прежде чем отдавать их через API, нужно определить смысл каждого значения. В документации Modern Treasury показано, почему учтенный, ожидающий и доступный балансы определяются отдельно.
Что записывается в ledger
Ledger – это финансовый регистр. Он хранит суммы на счетах, проводки, которые их изменили, и связь этих проводок с конкретными операциями.
Счет здесь не обязательно банковский. Он может отражать средства клиента в кошельке, задолженность перед продавцом, комиссию или сумму, по которой еще ожидается расчет.
Возьмем упрощенный маркетплейс. При проведении покупки на 30 долларов обязательство платформы перед клиентом уменьшается на 30, а перед продавцом – увеличивается на 30. Общая сумма обязательств не меняется. Меняется тот, кому платформа должна деньги. Сама эта внутренняя запись не означает банковского перевода.
При двойной записи изменения оформляются сбалансированными проводками по дебету и кредиту. Дебет не всегда означает «деньги ушли», а кредит – «деньги пришли»: результат зависит от типа счета. Для инженера важно, что у операции есть согласованные стороны, которые записываются вместе. Типы счетов и правила балансировки разобраны в руководстве TigerBeetle по финансовому учету.
Хранить готовый баланс для быстрого чтения вполне нормально. Ненормально, если число 100 превратилось в 70, а надежной записи о причине этого изменения нет.
Сумму нужно хранить вместе с валютой, целым числом в единицах заданного масштаба или в точном десятичном формате. Не у каждой валюты есть сотые доли. Для комиссий и конвертации также нужны явные правила округления. Практические примеры есть в описаниях валютных единиц Stripe и точных числовых типов PostgreSQL.
Как связаны компоненты
На схеме разделены шесть зон ответственности. Они могут находиться в одном приложении или в нескольких сервисах. Шесть блоков не означают, что нужно разворачивать шесть отдельных систем.
- Приложение: принимает поручение клиента и показывает результат
- Платежный сервис: отслеживает операцию, общается с провайдером и запрашивает нужные изменения в ledger
- Платежный провайдер: выполняет внешний платеж и сообщает о его состоянии
- Ledger: хранит сбалансированные проводки и управляет резервами по правилам продукта
- Представление баланса: отдает приложению понятные суммы. Это может быть прямой запрос к ledger или отдельная модель для чтения.
- Сверка: сравнивает записи ledger с отчетами банка или провайдера и выявляет расхождения.
Это референсная архитектура, а не описание заявленного клиентского внедрения. Стрелки показывают запросы и записи, а не буквальное движение денег. Изменения в ledger и действия провайдера выполняются в разных транзакционных границах.
Последнее важно. Запись о покупке в ledger не доказывает, что деньги поступили в банк продавца. Ответ API о принятом запросе тоже не обязательно подтверждает окончательный расчет. Для каждого перехода в учете нужно определить достаточное основание: для одних это разрешенное внутреннее действие, для других – подтверждение провайдера или банка.
Две покупки могут потратить одни и те же деньги
Представим, что при доступных 100 долларах одновременно приходят два запроса по 80. Оба читают значение 100. Оба решают, что денег достаточно. Если проверка и резервирование выполняются отдельно, система может разрешить потратить 160.
Более частое обновление баланса на экране проблему не решит. Система, которая принимает окончательное решение, должна проверять средства и резервировать их атомарно: либо происходит и то и другое, либо ничего. Второй запрос нужно отклонить или заново проверить с учётом уменьшившейся доступной суммы.
В зависимости от реализации для этого используют блокировки в транзакции базы данных, проверку версии или условную операцию самого ledger. Например, Modern Treasury описывает блокировки по балансу и версии. Значение из кеша, показанное пользователю, не должно само по себе давать разрешение на списание.
Тайм-аут не доказывает, что платеж не прошел
Платежный сервис отправил запрос. Провайдер его принял. Ответ потерялся.
Приложение видит тайм-аут. Но у провайдера платеж уже может существовать. Если немедленно создать новый, клиент рискует заплатить дважды.
У бизнес-операции должен быть постоянный идентификатор. Повторы этой операции должны приводить к одному и тому же предусмотренному результату, а не создавать еще один. Это идемпотентность. Она нужна и для запросов к провайдеру, и для команд внутреннего учета: защита провайдера не мешает вашему коду дважды записать одну проводку.
Разные шаги нельзя смешивать: резервирование, отправка платежа, его проведение и возврат требуют собственных постоянных идентификаторов команд. Это разные действия, а не повторы одного запроса.
Гарантии провайдеров ограничены. Например, в документации Stripe по идемпотентным запросам описаны срок хранения ключей и правила сопоставления запросов. Нельзя считать, что старый ключ защищает операцию вечно. Храните собственную связь между бизнес-операцией, объектами провайдера и транзакциями ledger.
Если результат неизвестен, сохраняйте это состояние и выясняйте исход через предусмотренный провайдером поиск операции или механизм повторов. Освобождать резерв только потому, что сетевой запрос завершился тайм-аутом, нельзя.
Уведомление – это свидетельство, а не новое поручение на платеж
Webhook – HTTP-уведомление, которое провайдер отправляет при наступлении события. Оно может прийти дважды или позже уведомления о более позднем событии. Оба случая прямо описаны в документации Stripe.
Для этой архитектуры мы рекомендуем проверить подлинность уведомления, надежно сохранить его, подтвердить доставку и затем обработать с учетом текущего состояния операции. Повторная доставка не должна создавать вторую проводку. Старое уведомление не должно без проверки возвращать завершенную операцию в предыдущее состояние.
Обратное направление тоже требует восстановления: что произойдет, если сервис сохранил операцию и упал до отправки запроса провайдеру? Намерение отправить запрос можно сохранить в надежной очереди работ. Один из вариантов – transactional outbox: локальное изменение и задание на отправку записываются одной транзакцией базы данных, после чего отправка повторяется при необходимости.
Это не объединяет внешний ledger, провайдера и вашу базу в одну транзакцию. Между ними все равно нужны постоянные идентификаторы, восстановление после сбоев и обработка частично выполненных действий.
Освободить резерв – не то же самое, что вернуть покупку
До проведения операции резерв можно снять по правилам отмены или истечения срока. После проведения изменение исходной финансовой записи уничтожило бы часть истории.
Поэтому возврат или исправление оформляют новой связанной транзакцией. Если после покупки на 30 долларов признан и проведен возврат на 10, в нашем примере остаются 80 учтенных и 80 доступных долларов – при отсутствии других резервов. При этом исходная покупка остается видна рядом с возвратом.
В модели отложенных переводов TigerBeetle разделены проведение, отмена и истечение срока. В жизненном цикле транзакций Modern Treasury проведенные финансовые записи также сохраняются, а обратная операция записывается отдельно. Это конкретные реализации этого различия, а не одинаковые API.
Нужно ли ждать поступления внешних средств, прежде чем зачислить возврат клиенту, определяется правилами продукта и учета. Система должна выполнять эти правила, а не считать «возврат запрошен» и «возврат получен» одним состоянием.
Зачем нужна сверка, если проводки сходятся
Ledger может идеально сходиться и при этом содержать ошибку. У дублирующей транзакции тоже могут быть сбалансированные проводки. А пропущенная транзакция вообще не нарушит баланс уже существующих записей.
Сверка сопоставляет внутренний учет с независимыми данными банка или провайдера. Проверяют идентификаторы операций, суммы, валюты и отчетные периоды. Учитывают ожидаемые задержки расчетов, комиссии, возвраты и корректировки. Сравнение только итоговых сумм может скрыть ошибки, которые взаимно компенсировались.
Если данные не совпали, нужно зафиксировать расхождение и собрать записи для его разбора. Не стоит незаметно менять баланс, пока он не совпадет с выпиской. Необходимую корректировку нужно согласовать, записать и связать с причиной. Сопоставление ledger с внешними счетами описано в документации Modern Treasury по сверке.
Что проверить, прежде чем считать процесс готовым
Успешный платеж – только первый тест. Проверьте также две одновременные покупки на одни средства, повторное уведомление, запоздавшее изменение статуса, тайм-аут после принятия платежа провайдером, частичный возврат и комиссию в выписке, которой не было в исходном запросе.
В каждом случае проверяйте проводки, доступную сумму и путь восстановления, а не только статус в интерфейсе.
Практический результат простой: если клиент спросит, почему изменился баланс, поддержка должна проследить операцию от запроса и резерва до проводок и внешнего подтверждения. Чтобы объяснить результат – никому не должно требоваться исправлять сам баланс вручную.