Все записи
7 мин

Партнёрку за вечер: денежный леджер в копейках и баг, который молча съедал все бонусы

вайбкодингденьгикартара

Если коротко, то за один вечер мы с Claude Code собрали для Картары партнёрскую программу. Отдельную от старой реферальной системы, на своём поддомене, с денежным леджером в копейках, кривой затухания комиссии и защитой от двойной выплаты, а под конец ревьюеры выловили баг, из-за которого партнёрский бонус не выдавался вообще никогда, причём делал он это абсолютно тихо. Вот честная история про то, что я там напроектировал, где сам себе подложил свинью и почему деньги в проде надо считать в копейках, а не в красивых рублях с точкой.

Зачем отдельная программа, если реферальная уже есть

У Картары давно работает реферальная система, та самая, что дарит токены за приглашённых друзей, и первая мысль была тупо прикрутить блогеров туда же. Но я довольно быстро остановился, потому что по своей природе это разные вещи. Реферальная система раздаёт внутреннюю валюту, токены, то есть по сути обещания, которые ничего нам напрямую не стоят, пока человек их не потратит. А партнёрка для блогеров это уже RevShare, реальные деньги, доля от выручки, которую мы кому-то должны отдать наружу, и смешивать эти два мира в одной таблице мне совсем не хотелось.

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

Леджер в копейках как единственный источник правды по деньгам

Главное архитектурное решение вечера это денежный леджер отдельной таблицей, и считаем мы в копейках, а не в рублях с дробной частью. Я тут даже не изобретал ничего нового, у меня в проекте уже есть история покупок, где цена хранится целым числом, и я просто сказал делать так же. Из моих заметок по той спеке так дословно и записано: деньги в копейках, как в истории покупок, и единый источник правды по деньгам сохраняется. То есть один источник правды, и точка.

Почему копейки, а не привычные рубли с запятой? Потому что числа с плавающей точкой и деньги это давняя классика граблей, на которых обжигались все, и я не хочу проверять, обожгусь ли я. Когда ты складываешь и вычитаешь дробные суммы тысячи раз, в какой-то момент вылезет копейка из ниоткуда или, наоборот, пропадёт, и ты будешь сидеть и гадать, откуда расхождение в балансе партнёра. А целые копейки складываются ровно, без сюрпризов. Леджер при этом не трогает существующие токеновые кошельки и транзакции вообще, и это его принципиальная черта, он про деньги и только про деньги.

Кривая комиссии: 40 на старте, 25 потом, и отдельный режим для героев

Дальше встал вопрос, как считать саму долю. Я не хотел плоский процент навечно, потому что это и нечестно к продукту, и легко превращается в дыру. Поэтому сделал кривую затухания комиссии: по дефолту партнёр стартует с 40 процентов и со временем оседает на 25. А для особо ценных людей, тех, кто реально гонит объём, есть отдельный режим, назовём его героическим, где старт 70 и затухание до 40. Этот режим не выдаётся автоматом, его ставят руками, то есть это не лазейка, а осознанное решение под конкретного человека.

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

Как не заплатить дважды за одно и то же

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

Сама модель партнёра тоже устроена не по принципу «пришёл и сразу деньги». Это заявка, потом модерация, и только потом активен. А уже активный партнёр сам себе делает потоки, навешивает UTM и промокоды, мне эта мысль зашла ещё в процессе, я её прямо надиктовал: сюда сразу можно накинуть, если блогер хочет разные ссылки с разными UTM или вообще без UTM, просто название потока и ссылка, и понятно, откуда переходы. То есть партнёр сам режет трафик на источники и видит, что у него стрельнуло, а я не превращаюсь в его персонального менеджера по ссылкам.

Косяк номер один: забыл собрать консилиум

Теперь про то, где я сам облажался, потому что без этого build-log был бы враньём. У меня на сложных спеках есть свой процесс, консилиум из AI-экспертов, которые ещё до кода атакуют решение с разных сторон. И вот по этой денежной спеке я его сначала тупо не запустил, начал сразу строить. Поймал себя на этом уже в процессе, написал ровно так: консилиум кстати по этой спеке почему не делал, косяк. Деньги это та самая тема, где цена ошибки высокая и проверить себя чужими глазами надо обязательно, а я проскочил этот гейт на драйве.

Запустил его задним числом. Но право последнего слова осталось за мной, установка была простая: строим всё до конца, не режем скоуп под предлогом «давай попроще». Консилиум хорош тем, что критикует архитектуру и реализацию, а не выкидывает мои фичи в бэклог, и эту границу я держу жёстко. Кстати, у меня уже была отдельная история про то, как я собрал консилиум из шести AI-экспертов и забыл добавить туда пользователя, так что, видимо, забывчивость с консилиумами это уже мой личный жанр.

Money-баг, который молчал: naive против aware datetime

А вот ради чего весь этот процесс и нужен. Ревьюеры поймали баг, который я бы сам, скорее всего, увидел только по жалобам, что бонусы не капают. Звучит безобидно: где-то сравнивались две даты, одна без таймзоны, другая с таймзоной. В Python такое сравнение просто падает с ошибкой, это не редкость и не катастрофа сама по себе. Но дальше начинается самое подлое: весь этот кусок был обёрнут в try/except по принципу «ну если что-то пойдёт не так, просто молча пропустим», и он молча пропускал, каждый раз.

Итог такой: выдача любого партнёрского промо со сроком действия тихо роняла исключение, его глотал except, и партнёрский бонус не выдавался никогда. Ни ошибки в логах, ни алерта, ни падения, система бодро рапортует, что всё ок, а денег у партнёра нет. Это худший тип бага в деньгах, не тот, что орёт, а тот, что улыбается и врёт. Если бы я выкатил это в прод без ревью, я бы узнал о проблеме от обиженных блогеров, а не от теста, и это уже репутация, а не строчка в коде.

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

Что осталось открытым и что я из этого вынес

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

А вывод по вечеру такой. Деньги в проекте это не та область, где можно вайбкодить на расслабоне и доверять зелёным галочкам. Считай в копейках целыми числами, защищай кассу блокировкой строки и проверкой состояния перед записью, снапшоть ставку в момент начисления, держи денежный леджер отдельно от всего остального как единственный источник правды и, бога ради, не заворачивай денежную логику в немой try/except, который глотает ошибки и улыбается. И не пропускай ревью на деньгах, даже если очень хочется построить всё за один присест, я пропустил консилиум, и не поймай ревьюеры этот datetime, я бы платил партнёрам ровно ноль и не знал об этом. Вот и делайте выводы.

Код тут, как обычно, писала нейросеть под моим управлением, я оркестрировал спеку, архитектуру и процесс, а Claude Code и ревьюеры тащили реализацию и вылавливали мои же дыры. Так же было и когда из случайного ресёрча выросли три фичи Картары за день: именно связка, где я держу рамку и не режу скоуп, а AI-агенты грызут детали и ловят баги, сделала так, что денежная фича родилась за вечер и при этом не вышла с миной внутри.