Если у вас в проекте одна и та же цифра живёт сразу в трёх-четырёх местах — в коде фронта, в коде бэка, в админке и где-то ещё в разметке для красоты — то рано или поздно она там разойдётся, и разойдётся именно в тот момент, когда человек захочет вам заплатить. У меня перед релизом так и вышло: за разбор сна бэкенд списывал 20 токенов, а мини-апп показывала и требовала 30, и любой пользователь с балансом от 20 до 29 упирался в стену и не мог купить то, на что у него по факту хватало денег. Починилось это не заплаткой в одном файле, а вынесением цены в единый источник правды, привязанный к админке, который и фронт, и бэк дёргают живьём. Дальше расскажу как вообще такое случается и почему это классика, на которую напорется любой, кто пилит mvp в одиночку.
Как вылез баг
Дело было на аудите перед релизом Картары — это мой Telegram Mini App с AI-персонажем, и там пользователь тратит токены на разные действия, в том числе на разбор сна. Я гонял всё подряд руками и наткнулся на штуку, от которой натурально взбесился: захожу с балансом, которого по идее хватает, а интерфейс говорит «не хватает, нужно 30». Лезу в бэкенд — а там за этот же сон списывается 20. То есть человек с балансом, скажем, 25 токенов видит красную кнопку и уходит, хотя ему хватало с запасом. Платящий юзер, который хотел нажать и заплатить, заблокирован зря.
Я тогда в рабочую сессию написал примерно следующее, цитирую почти дословно, потому что точнее не скажешь: «Ахуеть. Это надо везде проверить, че за хуйня. Почему не 1 источник правды по стоимости? который при чём в админке должен быть привязан. И фронт и бэк». И вот это «почему не один источник» — это и есть корень всей истории, а не конкретные 20 против 30.
Почему цифра вообще разошлась
А разошлась она потому, что её никто специально не дублировал — она расползлась сама. Сначала цена появляется в бэке, где происходит реальное списание, потом её надо показать на фронте, и ты вписываешь её во фронт, потом её надо отрисовать в каком-нибудь блоке «стоимость действия», и она попадает туда третьим экземпляром, а потом где-то ещё в подписи к кнопке четвёртым. И каждый из этих экземпляров живёт своей жизнью: ты поменял цену в одном месте, в админке или в коде бэка, а остальные три про это не знают и продолжают показывать старое. У меня цена дублировалась в трёх-четырёх вариантах отображения плюс отдельно в коде фронта и бэка, и когда я однажды что-то подкрутил, бэк уехал на 20, а витрина осталась на 30. Та же болячка вылезает и когда у одного продукта два фронта — Telegram Mini App и веб — и они вечно расходятся: любое значение, которое лежит в копиях, рано или поздно уезжает врозь.
Самое поганое в таких багах — что они не падают. Ничего не ломается, тесты зелёные, логи чистые, контейнер Up. Просто человек на той стороне видит одно, а система делает другое, и пока ты сам не сядешь и не пройдёшь сценарий руками с конкретным балансом — ты этого не увидишь. Это та же порода, что и баг, который я ловил раньше, когда друг открыл моё приложение с моего телефона и AI рассказал ему мою жизнь — снаружи всё работает, а внутри тихо протекает не туда.
Что сделали
Решение очевидное по формулировке и нудное по исполнению: один источник истины по ценам, привязанный к админке, и фронт, и бэк тянут значение оттуда живьём, а не хранят свои копии. То есть цена больше не «вписана» нигде в коде как магическое число — она лежит в одном месте, я её правлю в админке, и обе стороны видят ровно одно и то же в один и тот же момент. Сам код под это я руками не писал, я не умею и не скрываю — я оркестрировал, Claude Code написал реализацию, а моя работа была понять, в чём именно косяк, и сформулировать, что цена обязана быть единой и тянуться из админки. Это, кстати, отдельный навык, который мало кто проговаривает: AI прекрасно чинит то, что ты ему точно описал, но саму проблему «у меня цифра разъехалась по четырём местам» он за тебя не сформулирует, пока ты сам не пройдёшь сценарий и не наткнёшься.
Дальше пошли мелочи, которые на самом деле не мелочи. Я заставил сверить значения по умолчанию: в одном месте дефолтная цена стояла одна, а реальная стоимость действия — другая, и я свёл их, чтобы не было ситуации «показали одно, списали другое» уже на уровне дефолтов. Плюс поставили клампы — это ограничения на значения, чтобы цена не могла внезапно стать отрицательной или нулевой из-за кривого конфига.
Дырка в безопасности, которая вылезла попутно
И вот тут, когда мы уже копались в этой логике, вылезла вторая штука, которая мне не понравилась куда больше первой. Кусок кода, который берёт стоимость действия, не проверял значение, приходящее из конфига. То есть если в конфиг по какой-то причине прилетал ноль — нулевая стоимость — система спокойно создавала транзакцию на ноль. Нулевую транзакцию. Действие выполняется, токены не списываются, а формально всё «прошло». Один кривой конфиг, и ты бесплатно раздаёшь то, что должно стоить денег, и никто даже не пикнет, потому что ошибки-то нет.
Это ровно тот случай, где «и фронт, и бэк тянут цену из одного места» недостаточно — надо ещё проверять, что само значение вообще вменяемое. Поэтому проверку добавили: значение из конфига больше не принимается на веру, оно валидируется, прежде чем превратиться в реальную транзакцию. Всё это вместе ушло одним пакетом изменений — единый источник цен плюс клампы плюс эта security-заплатка, потому что разделять их не было смысла, это одна и та же болезнь на разных стадиях.
Что я из этого вынес
Главный вывод даже не про токены. Single source of truth — это не красивая абстракция из книжек по архитектуре, которую можно отложить «на потом, когда будет команда». Это штука, которая прямо сейчас, на mvp в одного, либо защищает вашу оплату, либо тихо её ломает. Любая величина, которая важна для денег — цена, лимит, баланс, стоимость действия — должна жить в одном месте и тянуться оттуда всеми, кто её показывает или применяет. Как только у неё появляется второй экземпляр «чтобы удобнее было отрисовать» — вы уже посадили мину, она просто пока не взорвалась.
Второй вывод — про то, как такие баги вообще находятся. Не код-ревью их ловит и не тесты, а тупой ручной проход по сценарию глазами живого человека с конкретными цифрами на балансе. Я нашёл это, потому что сел и сам потыкал перед релизом, как обычный юзер, а не как разработчик, который знает, куда нажимать чтобы работало. И это та же привычка, которая в своё время вытащила мне другие косяки — в той же Картаре я так же руками докручивал детали, пока неделя не превратила нумерологию из «нахуя это вообще» в фичу на проде.
И последнее, для тех, кто пилит что-то для бизнеса или продаёт нейросетевые приложения. Вы можете идеально настроить AI-генерацию контента, накрутить умного агента, собрать красивый интерфейс — а потом потерять платящего человека на ровном месте, потому что одна несчастная цифра показана не та, что списывается. Деньги текут через самые скучные места: через цену, через баланс, через кнопку «оплатить». Вот туда и смотрите в первую очередь, а уже потом в магию. Вот и делайте выводы.